feat: triage streams with open, close #121

Merged
talksik merged 7 commits from triage-streams into master 2026-04-07 23:11:13 +00:00
30 changed files with 669 additions and 402 deletions
-1
View File
@@ -127,7 +127,6 @@ func main() {
mux.Handle("GET /networks", withAuth(h.ListNetworks))
mux.Handle("GET /networks/{id}", withAuth(h.GetNetwork))
mux.Handle("POST /networks/{id}/members", withAuth(h.AddMembersToNetwork))
mux.Handle("PUT /networks/{id}/message-retention", withAuth(h.SetMessageRetentionHours))
// mux.Handle("DELETE /networks/{id}/members/{humanId}", withAuth(h.RemoveMemberFromNetwork))
// Network Invitations
-76
View File
@@ -61,7 +61,6 @@ type Network struct {
Name string `json:"name"`
AdminHuman Human `json:"admin_human"`
Humans []Human `json:"humans"`
MessageRetentionHours int `json:"message_retention_hours"`
CreatedAt time.Time `json:"created_at"`
}
@@ -91,14 +90,6 @@ type AddMembersToNetworkRequest struct {
EmailAddresses []string `json:"email_addresses"`
}
type SetOpenStreamCapacityRequest struct {
Capacity int `json:"capacity"`
}
type SetMessageRetentionHoursRequest struct {
Hours int `json:"hours"`
}
type MembersRequest struct {
Emails []string `json:"emails"`
}
@@ -675,72 +666,6 @@ func (h *Handler) RevokeInvitation(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// SetMessageRetentionHours updates the message retention window for a network (admin-only)
func (h *Handler) SetMessageRetentionHours(w http.ResponseWriter, r *http.Request) {
humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
networkID := r.PathValue("id")
if networkID == "" {
http.Error(w, "network id is required", http.StatusBadRequest)
return
}
// Fetch network to verify admin
net, err := h.networkSvc.GetByID(r.Context(), networkID)
if err != nil {
if errors.Is(err, network.ErrNotFound) {
http.Error(w, "network not found", http.StatusNotFound)
return
}
slog.Error("failed to get network", "error", err, "network_id", networkID)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if net.AdminHumanId != humanId {
http.Error(w, "only the network admin can change this setting", http.StatusForbidden)
return
}
var req SetMessageRetentionHoursRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if err := h.networkSvc.SetMessageRetentionHours(r.Context(), networkID, req.Hours); err != nil {
if errors.Is(err, network.ErrInvalidRetentionHours) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
slog.Error("failed to set message retention hours", "error", err, "network_id", networkID)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
// Return updated network
updatedNet, err := h.networkSvc.GetByID(r.Context(), networkID)
if err != nil {
slog.Error("failed to get network after update", "error", err, "network_id", networkID)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
resp, err := h.networkToDTO(r.Context(), updatedNet)
if err != nil {
slog.Error("failed to convert network to DTO", "error", err, "network_id", networkID)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// DownloadParticleMedia returns a fresh signed download URL for media/file particles
func (h *Handler) DownloadParticleMedia(w http.ResponseWriter, r *http.Request) {
_, ok := middleware.EmailFromContext(r.Context())
@@ -1056,7 +981,6 @@ func (h *Handler) networkToDTO(ctx context.Context, n *network.Network) (Network
Name: n.Name,
AdminHuman: humanToDTO(adminHuman),
Humans: humans,
MessageRetentionHours: n.MessageRetentionHours,
CreatedAt: n.CreatedAt,
}, nil
}
-3
View File
@@ -7,9 +7,6 @@ type Network struct {
Name string
AdminHumanId string
MemberHumanIds []string
OpenStreamCapacity int
OpenStreamCount int
MessageRetentionHours int
CreatedAt time.Time
}
+6 -21
View File
@@ -35,7 +35,6 @@ type repository interface {
getMemberHumanIds(ctx context.Context, networkID string) ([]string, error)
getNetworksForHuman(ctx context.Context, humanId string) ([]*Network, error)
isMember(ctx context.Context, networkID, humanId string) (bool, error)
updateMessageRetentionHours(ctx context.Context, id string, hours int) error
// Invitations
createInvitation(ctx context.Context, networkID, email string) error
@@ -61,9 +60,9 @@ func (r *repositoryImpl) create(ctx context.Context, name, adminHumanId string)
var n Network
err = r.pool.QueryRow(ctx,
`INSERT INTO networks (id, name, admin_human_id) VALUES ($1, $2, $3)
RETURNING id, name, admin_human_id, open_stream_capacity, open_stream_count, message_retention_hours, created_at`,
RETURNING id, name, admin_human_id, created_at`,
id.String(), name, adminHumanId,
).Scan(&n.ID, &n.Name, &n.AdminHumanId, &n.OpenStreamCapacity, &n.OpenStreamCount, &n.MessageRetentionHours, &n.CreatedAt)
).Scan(&n.ID, &n.Name, &n.AdminHumanId, &n.CreatedAt)
if err != nil {
return nil, err
}
@@ -75,9 +74,9 @@ func (r *repositoryImpl) create(ctx context.Context, name, adminHumanId string)
func (r *repositoryImpl) getByID(ctx context.Context, id string) (*Network, error) {
var n Network
err := r.pool.QueryRow(ctx,
`SELECT id, name, admin_human_id, open_stream_capacity, open_stream_count, message_retention_hours, created_at FROM networks WHERE id = $1`,
`SELECT id, name, admin_human_id, created_at FROM networks WHERE id = $1`,
id,
).Scan(&n.ID, &n.Name, &n.AdminHumanId, &n.OpenStreamCapacity, &n.OpenStreamCount, &n.MessageRetentionHours, &n.CreatedAt)
).Scan(&n.ID, &n.Name, &n.AdminHumanId, &n.CreatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, errNotFound
@@ -158,7 +157,7 @@ func (r *repositoryImpl) getMemberHumanIds(ctx context.Context, networkID string
func (r *repositoryImpl) getNetworksForHuman(ctx context.Context, humanId string) ([]*Network, error) {
rows, err := r.pool.Query(ctx,
`SELECT n.id, n.name, n.admin_human_id, n.open_stream_capacity, n.open_stream_count, n.message_retention_hours, n.created_at
`SELECT n.id, n.name, n.admin_human_id, n.created_at
FROM networks n
WHERE n.admin_human_id = $1
OR EXISTS (SELECT 1 FROM network_members nm WHERE nm.network_id = n.id AND nm.human_id = $1)`,
@@ -172,7 +171,7 @@ func (r *repositoryImpl) getNetworksForHuman(ctx context.Context, humanId string
var networks []*Network
for rows.Next() {
var n Network
if err := rows.Scan(&n.ID, &n.Name, &n.AdminHumanId, &n.OpenStreamCapacity, &n.OpenStreamCount, &n.MessageRetentionHours, &n.CreatedAt); err != nil {
if err := rows.Scan(&n.ID, &n.Name, &n.AdminHumanId, &n.CreatedAt); err != nil {
return nil, err
}
networks = append(networks, &n)
@@ -269,17 +268,3 @@ func (r *repositoryImpl) deleteInvitation(ctx context.Context, networkID, email
)
return err
}
func (r *repositoryImpl) updateMessageRetentionHours(ctx context.Context, id string, hours int) error {
result, err := r.pool.Exec(ctx,
`UPDATE networks SET message_retention_hours = $1 WHERE id = $2`,
hours, id,
)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return errNotFound
}
return nil
}
-13
View File
@@ -26,8 +26,6 @@ type Service interface {
RemoveMember(ctx context.Context, networkID, humanId string) error
ListForHuman(ctx context.Context, humanId string) ([]*Network, error)
IsMember(ctx context.Context, networkID, humanId string) (bool, error)
// SetMessageRetentionHours sets how long messages remain visible (24336 hours).
SetMessageRetentionHours(ctx context.Context, id string, hours int) error
// Invitations (email-based, for users who haven't registered yet)
InviteByEmail(ctx context.Context, networkID string, emails []string) error
@@ -118,17 +116,6 @@ func (s *serviceImpl) IsMember(ctx context.Context, networkID, humanId string) (
return s.repo.isMember(ctx, networkID, humanId)
}
func (s *serviceImpl) SetMessageRetentionHours(ctx context.Context, id string, hours int) error {
if hours < 24 || hours > 336 {
return ErrInvalidRetentionHours
}
err := s.repo.updateMessageRetentionHours(ctx, id, hours)
if errors.Is(err, errNotFound) {
return ErrNotFound
}
return err
}
// Invitation methods
func (s *serviceImpl) InviteByEmail(ctx context.Context, networkID string, emails []string) error {
@@ -1,2 +0,0 @@
ALTER TABLE networks
DROP COLUMN IF EXISTS message_retention_hours;
@@ -1,2 +0,0 @@
ALTER TABLE networks
ADD COLUMN message_retention_hours INTEGER NOT NULL DEFAULT 24;
@@ -0,0 +1,3 @@
ALTER TABLE networks
ADD COLUMN open_stream_capacity INTEGER NOT NULL DEFAULT 5,
ADD COLUMN open_stream_count INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,3 @@
ALTER TABLE networks
DROP COLUMN IF EXISTS open_stream_capacity,
DROP COLUMN IF EXISTS open_stream_count;
-9
View File
@@ -178,15 +178,6 @@ class ApiClient {
);
}
async setMessageRetentionHours(networkId: string, hours: number) {
return this.request(
NetworkSchema,
"PUT",
`/networks/${networkId}/message-retention`,
{ hours },
);
}
// --- Invitations ---
async listNetworkInvitations(networkId: string) {
+3 -3
View File
@@ -14,7 +14,6 @@ export const NetworkSchema = z.object({
name: z.string(),
admin_human: HumanSchema,
humans: z.array(HumanSchema),
message_retention_hours: z.number(),
created_at: z.coerce.date(),
});
@@ -81,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>;
@@ -175,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()),
@@ -186,6 +185,7 @@ export const ParticleSchema = z.discriminatedUnion("type", [
last_child_created_at: z.coerce.date().optional(),
// Array of humanIds currently in the huddle (updated via LiveKit webhooks)
huddle_active_participants: z.array(z.string()).optional(),
status: z.enum(["open", "closed"]).optional(),
}),
ParticleBaseSchema.extend({
type: z.literal("folder"), properties: FolderPropertiesSchema,
+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 }
+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>
);
}
+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,
-20
View File
@@ -1,20 +0,0 @@
import { useMemo } from "react";
/**
* Returns true when a stream is within the last 10% of its retention window.
* For example, with 24h retention, this fires when < 2.4h remain.
*/
export function useExpiringSoon(
lastChildCreatedAt: Date | undefined,
retentionHours: number,
): boolean {
return useMemo(() => {
if (!lastChildCreatedAt) return false;
const retentionMs = retentionHours * 60 * 60 * 1000;
const expiresAt = lastChildCreatedAt.getTime() + retentionMs;
const remaining = expiresAt - Date.now();
return remaining > 0 && remaining < retentionMs * 0.1;
}, [lastChildCreatedAt, retentionHours]);
}
-13
View File
@@ -1,13 +0,0 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient } from "@/api/client";
export function useSetMessageRetention(networkId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (hours: number) =>
apiClient.setMessageRetentionHours(networkId, hours),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["networks"] });
},
});
}
+39 -28
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from "react";
import { useState, useEffect } from "react";
import {
subscribeToParticle,
subscribeToParticleChildren,
@@ -26,13 +26,12 @@ export function useLiveParticle(path: ParticlePath): UseLiveParticleResult {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const docPath = useMemo(() => toFirestoreDocPath(path), [path]);
useEffect(() => {
setIsLoading(true);
setError(null);
setParticle(null);
const docPath = toFirestoreDocPath(path);
const unsubscribe = subscribeToParticle(
docPath,
(data) => {
@@ -46,7 +45,7 @@ export function useLiveParticle(path: ParticlePath): UseLiveParticleResult {
);
return unsubscribe;
}, [docPath]);
}, [path]);
return { particle, isLoading, error };
}
@@ -57,47 +56,59 @@ interface UseLiveParticleChildrenResult {
error: Error | null;
}
interface UseLiveParticleChildrenParams {
orderByField?: string;
orderDirection?: "asc" | "desc";
visibilityScopes?: string[];
onAdded?: (child: Particle) => void;
onRemoved?: (child: Particle, updatedChildren: Particle[]) => void;
whereFilter?: QueryFieldFilterConstraint;
}
export function useLiveParticleChildren(
path: ParticlePath,
orderByField: string = "created_at",
orderDirection: "asc" | "desc" = "desc",
visibilityScopes?: string[],
onAdded?: (child: Particle) => void,
onRemoved?: (child: Particle, updatedChildren: Particle[]) => void,
whereFilter?: QueryFieldFilterConstraint
{
orderByField = "created_at",
orderDirection = "desc",
visibilityScopes,
onAdded,
onRemoved,
whereFilter,
}: UseLiveParticleChildrenParams = {}
): UseLiveParticleChildrenResult {
const [children, setChildren] = useState<Particle[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const collectionPath = useMemo(() => toFirestoreChildrenPath(path), [path]);
useEffect(() => {
setIsLoading(true);
setError(null);
setChildren([]);
const collectionPath = toFirestoreChildrenPath(path);
const unsubscribe = subscribeToParticleChildren(
collectionPath,
(data) => {
setChildren(data);
setIsLoading(false);
},
(err) => {
setError(err);
setIsLoading(false);
},
visibilityScopes,
orderByField,
orderDirection,
onAdded,
onRemoved,
whereFilter,
{
onData: (data) => {
setChildren(data);
setIsLoading(false);
},
onError: (err) => {
setError(err);
setIsLoading(false);
},
visibilityScopes,
orderByField,
orderDirection,
onAdded,
onRemoved,
whereFilter,
}
);
return unsubscribe;
// FIX: do we need to listen to more deps? Would that cause side effects that break behavior
}, [collectionPath]);
}, [path]);
return { children, isLoading, error };
}
+7 -32
View File
@@ -1,12 +1,10 @@
import { useEffect, useMemo, useState } from "react";
import { useMemo } 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 { useNetwork } from "@/hooks/use-networks";
type StreamParticle = Particle & { type: "stream"; properties: StreamProperties };
export type StreamParticle = Particle & { type: "stream"; properties: StreamProperties };
function useVisibilityScopes(userId?: string, networkId?: string) {
return useMemo(() => {
@@ -27,37 +25,14 @@ export function useStreamParticles(path: ParticlePath): UseStreamParticlesResult
const { networkId } = parseParticlePath(path);
const user = useAuthStore((s) => s.user);
const visibilityScopes = useVisibilityScopes(user?.id, networkId);
const network = useNetwork(networkId);
const retentionHours = network?.message_retention_hours ?? 24;
const [recencyCutoff, setRecencyCutoff] = useState(() => {
const d = new Date();
d.setHours(d.getHours() - retentionHours);
return Timestamp.fromDate(d);
});
useEffect(() => {
// Recalculate immediately when retention changes
const d = new Date();
d.setHours(d.getHours() - retentionHours);
setRecencyCutoff(Timestamp.fromDate(d));
const interval = setInterval(() => {
const d = new Date();
d.setHours(d.getHours() - retentionHours);
setRecencyCutoff(Timestamp.fromDate(d));
}, 60 * 60 * 1000);
return () => clearInterval(interval);
}, [retentionHours]);
const { children, isLoading } = useLiveParticleChildren(
path,
"last_child_created_at",
"desc",
visibilityScopes,
undefined,
undefined,
where("last_child_created_at", ">=", recencyCutoff),
{
orderByField: "last_child_created_at",
orderDirection: "desc",
visibilityScopes,
}
);
const streams = useMemo(
+7 -13
View File
@@ -4,8 +4,6 @@ import type { Particle } from "@/api/types";
import { useLiveParticleChildren } from "@/hooks/use-particle";
import { parseParticlePath, toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path";
import { updateStreamPlaybackMarker } from "@/lib/firestore-particles";
import { where, Timestamp } from "firebase/firestore";
import { useNetwork } from "@/hooks/use-networks";
// --- Playback reducer (ID-based) ---
@@ -96,9 +94,6 @@ export function useStreamPlayback(
path: ParticlePath,
): UseStreamPlaybackResult {
const userId = useAuthStore((s) => s.user?.id);
const { networkId } = parseParticlePath(path);
const network = useNetwork(networkId);
const retentionHours = network?.message_retention_hours ?? 24;
const [state, dispatch] = useReducer(playbackReducer, initialState);
// Track the stream ID we've initialized for, to reset when navigating between streams
const initializedForRef = useRef<string | null>(null);
@@ -118,15 +113,14 @@ export function useStreamPlayback(
});
});
const [recencyCutoff] = useState(() => {
const d = new Date();
d.setHours(d.getHours() - retentionHours);
return Timestamp.fromDate(d);
});
const { children } = useLiveParticleChildren(
path, "created_at", "asc", undefined, onParticleAdded, onParticleRemoved,
where("created_at", ">=", recencyCutoff),
path,
{
orderByField: "created_at",
orderDirection: "asc",
onAdded: onParticleAdded,
onRemoved: onParticleRemoved
}
);
// Derive current index and particle from ID
+62 -10
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({
@@ -128,10 +129,17 @@ export async function getParticle(docPath: string): Promise<Particle | null> {
return doc.data();
}
export interface GetParticleChildrenOptions {
orderByField: string;
orderDirection: "asc" | "desc";
}
export async function getParticleChildren(
collectionPath: string,
orderByField: string = "created_at",
orderDirection: "asc" | "desc" = "asc",
{
orderByField = "created_at",
orderDirection = "asc",
}: GetParticleChildrenOptions = { orderByField: "created_at", orderDirection: "asc" },
): Promise<Particle[]> {
const q = query(
typedCollection(collectionPath),
@@ -141,16 +149,29 @@ export async function getParticleChildren(
return snap.docs.map((d) => d.data());
}
export interface SubscribeToParticleChildrenOptions {
onData: (children: Particle[]) => void;
onError: (error: Error) => void;
visibilityScopes?: string[];
orderByField?: string;
orderDirection?: "asc" | "desc";
onAdded?: (child: Particle) => void;
onRemoved?: (child: Particle, updatedChildren: Particle[]) => void;
whereFilter?: QueryFieldFilterConstraint;
}
export function subscribeToParticleChildren(
collectionPath: string,
onData: (children: Particle[]) => void,
onError: (error: Error) => void,
visibilityScopes: string[] = [],
orderByField: string = "created_at",
orderDirection: "asc" | "desc" = "desc",
onAdded?: (child: Particle) => void,
onRemoved?: (child: Particle, updatedChildren: Particle[]) => void,
whereFilter?: QueryFieldFilterConstraint,
{
onData,
onError,
visibilityScopes = [],
orderByField = "created_at",
orderDirection = "desc",
onAdded,
onRemoved,
whereFilter,
}: SubscribeToParticleChildrenOptions
): Unsubscribe {
let q = query(typedCollection(collectionPath), orderBy(orderByField, orderDirection));
if (visibilityScopes.length > 0) {
@@ -225,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,
@@ -278,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,