From e7384cb45c1704edfd66ae3d1308127c3b81d7e2 Mon Sep 17 00:00:00 2001 From: talksik Date: Tue, 7 Apr 2026 14:21:25 -0700 Subject: [PATCH 1/7] refactor: use interfaces for function params --- js/src/features/particles/folder-view.tsx | 2 +- js/src/hooks/use-particle.ts | 67 +++++++++++++---------- js/src/hooks/use-stream-particles.ts | 37 ++----------- js/src/lib/firestore-particles.ts | 40 ++++++++++---- 4 files changed, 76 insertions(+), 70 deletions(-) diff --git a/js/src/features/particles/folder-view.tsx b/js/src/features/particles/folder-view.tsx index 34bcaed..b71ca82 100644 --- a/js/src/features/particles/folder-view.tsx +++ b/js/src/features/particles/folder-view.tsx @@ -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 ( diff --git a/js/src/hooks/use-particle.ts b/js/src/hooks/use-particle.ts index 2f99b90..1397c66 100644 --- a/js/src/hooks/use-particle.ts +++ b/js/src/hooks/use-particle.ts @@ -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(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([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(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 }; } diff --git a/js/src/hooks/use-stream-particles.ts b/js/src/hooks/use-stream-particles.ts index dac1544..3ad1254 100644 --- a/js/src/hooks/use-stream-particles.ts +++ b/js/src/hooks/use-stream-particles.ts @@ -1,10 +1,8 @@ -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 }; @@ -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( diff --git a/js/src/lib/firestore-particles.ts b/js/src/lib/firestore-particles.ts index 10ebcbb..fa2327f 100644 --- a/js/src/lib/firestore-particles.ts +++ b/js/src/lib/firestore-particles.ts @@ -128,10 +128,17 @@ export async function getParticle(docPath: string): Promise { 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 { const q = query( typedCollection(collectionPath), @@ -141,16 +148,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) { -- 2.54.0 From 5ec24d9542de8921a7d40e385e5cd46e654d8f2a Mon Sep 17 00:00:00 2001 From: talksik Date: Tue, 7 Apr 2026 14:42:06 -0700 Subject: [PATCH 2/7] cleanup message retention code --- js/src/api/client.ts | 9 --- js/src/api/types.ts | 2 +- js/src/features/network-settings.tsx | 62 ------------------- .../features/particles/particle-list-view.tsx | 10 --- js/src/features/particles/stream-card.tsx | 11 +--- js/src/hooks/use-expiring-soon.ts | 20 ------ js/src/hooks/use-network-settings.ts | 13 ---- js/src/hooks/use-stream-playback.ts | 20 +++--- 8 files changed, 9 insertions(+), 138 deletions(-) delete mode 100644 js/src/hooks/use-expiring-soon.ts delete mode 100644 js/src/hooks/use-network-settings.ts diff --git a/js/src/api/client.ts b/js/src/api/client.ts index bac9f8f..378dfbf 100644 --- a/js/src/api/client.ts +++ b/js/src/api/client.ts @@ -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) { diff --git a/js/src/api/types.ts b/js/src/api/types.ts index 3e7ef23..0619974 100644 --- a/js/src/api/types.ts +++ b/js/src/api/types.ts @@ -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(), }); @@ -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, diff --git a/js/src/features/network-settings.tsx b/js/src/features/network-settings.tsx index 0368fb8..b2ca79d 100644 --- a/js/src/features/network-settings.tsx +++ b/js/src/features/network-settings.tsx @@ -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>(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 ( -
-
-

Messages disappear after

-

{formatRetentionDays(days * 24)}

-
- - - Older messages are no longer visible to anyone. - -
- ); -} - export default function NetworkSettingsPage() { const navigate = useNavigate(); const { networkId } = useParams<{ networkId: string }>(); @@ -240,18 +190,6 @@ export default function NetworkSettingsPage() { ))} - {isAdmin && network && ( - <> - - - - - - )} - {isAdmin && network && ( diff --git a/js/src/features/particles/particle-list-view.tsx b/js/src/features/particles/particle-list-view.tsx index dd8df4a..cb49c18 100644 --- a/js/src/features/particles/particle-list-view.tsx +++ b/js/src/features/particles/particle-list-view.tsx @@ -9,7 +9,6 @@ import { FileText, CircleCheck, StickyNote, - Timer, Headphones, type LucideIcon, } from "lucide-react"; @@ -28,7 +27,6 @@ import { Progress } from "@/components/ui/progress"; import { Small } from "@/components/ui/typography"; import type { Particle, StreamProperties } from "@/api/types"; 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"; @@ -97,11 +95,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; @@ -201,9 +194,6 @@ function StreamRow({ {huddleCount} )} - {expiringSoon && ( - - )} {latestChild && ( (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(function S {huddleCount} )} - {expiringSoon && ( - - )} {latestChild && ( { - 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]); -} diff --git a/js/src/hooks/use-network-settings.ts b/js/src/hooks/use-network-settings.ts deleted file mode 100644 index 002c936..0000000 --- a/js/src/hooks/use-network-settings.ts +++ /dev/null @@ -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"] }); - }, - }); -} diff --git a/js/src/hooks/use-stream-playback.ts b/js/src/hooks/use-stream-playback.ts index 6abd9f6..5e0f7f6 100644 --- a/js/src/hooks/use-stream-playback.ts +++ b/js/src/hooks/use-stream-playback.ts @@ -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(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 -- 2.54.0 From ff6891f57e2d54c8a79dda3caa6c1c768eaa0862 Mon Sep 17 00:00:00 2001 From: talksik Date: Tue, 7 Apr 2026 15:09:22 -0700 Subject: [PATCH 3/7] support open / closed streams - Tabs for viewing separately - Context menu to close / open streams - Update stream particle status field --- js/src/api/types.ts | 4 +- js/src/components/ui/context-menu.tsx | 261 ++++++++++++++++++ js/src/components/ui/tabs.tsx | 90 ++++++ js/src/features/compose/compose-overlay.tsx | 1 - js/src/features/network-root.tsx | 47 ++-- js/src/features/particles/folder-view.tsx | 2 +- .../features/particles/particle-grid-view.tsx | 30 +- .../features/particles/particle-list-view.tsx | 44 +-- .../particles/particle-view-resolver.tsx | 7 +- .../particles/stream-context-menu.tsx | 35 +++ js/src/hooks/use-create-particle.ts | 5 +- js/src/hooks/use-particle.ts | 2 +- js/src/hooks/use-stream-particles.ts | 2 +- js/src/lib/firestore-particles.ts | 32 +++ 14 files changed, 496 insertions(+), 66 deletions(-) create mode 100644 js/src/components/ui/context-menu.tsx create mode 100644 js/src/components/ui/tabs.tsx create mode 100644 js/src/features/particles/stream-context-menu.tsx diff --git a/js/src/api/types.ts b/js/src/api/types.ts index 0619974..49cf4bf 100644 --- a/js/src/api/types.ts +++ b/js/src/api/types.ts @@ -80,7 +80,6 @@ export type DepotObject = z.infer; export const StreamPropertiesSchema = z.object({ name: z.string(), - status: z.enum(["open", "closed"]), description: z.string().optional(), }); export type StreamProperties = z.infer; @@ -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()), diff --git a/js/src/components/ui/context-menu.tsx b/js/src/components/ui/context-menu.tsx new file mode 100644 index 0000000..25fd8ab --- /dev/null +++ b/js/src/components/ui/context-menu.tsx @@ -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) { + return +} + +function ContextMenuTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function ContextMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuContent({ + className, + ...props +}: React.ComponentProps & { + side?: "top" | "right" | "bottom" | "left" +}) { + return ( + + + + ) +} + +function ContextMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function ContextMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function ContextMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuCheckboxItem({ + className, + children, + checked, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function ContextMenuRadioItem({ + className, + children, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function ContextMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function ContextMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +} diff --git a/js/src/components/ui/tabs.tsx b/js/src/components/ui/tabs.tsx new file mode 100644 index 0000000..05f469f --- /dev/null +++ b/js/src/components/ui/tabs.tsx @@ -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) { + return ( + + ) +} + +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 & + VariantProps) { + return ( + + ) +} + +function TabsTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants } diff --git a/js/src/features/compose/compose-overlay.tsx b/js/src/features/compose/compose-overlay.tsx index 26ff7b9..460d7b5 100644 --- a/js/src/features/compose/compose-overlay.tsx +++ b/js/src/features/compose/compose-overlay.tsx @@ -303,7 +303,6 @@ export function ComposeOverlay({ networkId, properties: { name: streamName, - status: "open", }, createdByHumanId: userId, visibleTo, diff --git a/js/src/features/network-root.tsx b/js/src/features/network-root.tsx index 174bc76..a83f8d8 100644 --- a/js/src/features/network-root.tsx +++ b/js/src/features/network-root.tsx @@ -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 ( -
- {/* Scrollable content */} -
- {viewMode === "list" ? ( - - ) : ( - - )} -
- - {/* Fixed overlays */} -
+
+ {/* Top bar — stays in place */} +
+ setStatusTab(v as "open" | "closed")} + > + + Open + Closed + + @@ -70,6 +76,15 @@ export default function NetworkRoot() {
+ {/* Scrollable content */} +
+ {viewMode === "list" ? ( + + ) : ( + + )} +
+
diff --git a/js/src/features/particles/folder-view.tsx b/js/src/features/particles/folder-view.tsx index b71ca82..34bcaed 100644 --- a/js/src/features/particles/folder-view.tsx +++ b/js/src/features/particles/folder-view.tsx @@ -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 ( diff --git a/js/src/features/particles/particle-grid-view.tsx b/js/src/features/particles/particle-grid-view.tsx index 5fb569b..1961668 100644 --- a/js/src/features/particles/particle-grid-view.tsx +++ b/js/src/features/particles/particle-grid-view.tsx @@ -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 (
{streams.map((stream, index) => ( - el?.scrollIntoView({ block: "nearest" }) : undefined} - particle={stream} - networkId={networkId} - onClick={() => navigate(`/${networkId}/${stream.id}`)} - isSelected={index === selectedIndex} - shortcutKey={index < 9 ? index + 1 : undefined} - /> + + el?.scrollIntoView({ block: "nearest" }) : undefined} + particle={stream} + networkId={networkId} + onClick={() => navigate(`/${networkId}/${stream.id}`)} + isSelected={index === selectedIndex} + shortcutKey={index < 9 ? index + 1 : undefined} + /> + ))}
); diff --git a/js/src/features/particles/particle-list-view.tsx b/js/src/features/particles/particle-list-view.tsx index cb49c18..d765ef2 100644 --- a/js/src/features/particles/particle-list-view.tsx +++ b/js/src/features/particles/particle-list-view.tsx @@ -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 (
{streams.map((stream, index) => ( -
el?.scrollIntoView({ block: "nearest" }) : undefined} - > - navigate(`/${networkId}/${stream.id}`)} - isSelected={index === selectedIndex} - shortcutKey={index < 9 ? index + 1 : undefined} - /> - {index < streams.length - 1 && } -
+ +
el?.scrollIntoView({ block: "nearest" }) : undefined} + > + navigate(`/${networkId}/${stream.id}`)} + isSelected={index === selectedIndex} + shortcutKey={index < 9 ? index + 1 : undefined} + /> + {index < streams.length - 1 && } +
+
))}
); diff --git a/js/src/features/particles/particle-view-resolver.tsx b/js/src/features/particles/particle-view-resolver.tsx index 469f0b0..7c984a1 100644 --- a/js/src/features/particles/particle-view-resolver.tsx +++ b/js/src/features/particles/particle-view-resolver.tsx @@ -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 ; default: - if (isContainerType(particle.type)) { - return ; - } return (

diff --git a/js/src/features/particles/stream-context-menu.tsx b/js/src/features/particles/stream-context-menu.tsx new file mode 100644 index 0000000..d065f3b --- /dev/null +++ b/js/src/features/particles/stream-context-menu.tsx @@ -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 ( + + {children} + + + {isOpen ? "Close stream" : "Open stream"} + + + + ); +} diff --git a/js/src/hooks/use-create-particle.ts b/js/src/hooks/use-create-particle.ts index 263e18e..66e8def 100644 --- a/js/src/hooks/use-create-particle.ts +++ b/js/src/hooks/use-create-particle.ts @@ -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, diff --git a/js/src/hooks/use-particle.ts b/js/src/hooks/use-particle.ts index 1397c66..e989a96 100644 --- a/js/src/hooks/use-particle.ts +++ b/js/src/hooks/use-particle.ts @@ -74,7 +74,7 @@ export function useLiveParticleChildren( onAdded, onRemoved, whereFilter, - }: UseLiveParticleChildrenParams + }: UseLiveParticleChildrenParams = {} ): UseLiveParticleChildrenResult { const [children, setChildren] = useState([]); const [isLoading, setIsLoading] = useState(true); diff --git a/js/src/hooks/use-stream-particles.ts b/js/src/hooks/use-stream-particles.ts index 3ad1254..3e2ab64 100644 --- a/js/src/hooks/use-stream-particles.ts +++ b/js/src/hooks/use-stream-particles.ts @@ -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(() => { diff --git a/js/src/lib/firestore-particles.ts b/js/src/lib/firestore-particles.ts index fa2327f..9f20a17 100644 --- a/js/src/lib/firestore-particles.ts +++ b/js/src/lib/firestore-particles.ts @@ -63,6 +63,7 @@ const particleConverter: FirestoreDataConverter = { : 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( return ref.id; } +export async function createStreamParticle( + collectionPath: string, + properties: ParticlePropertiesMap["stream"], + createdByHumanId: string, + visibleTo?: string[], +): Promise { + 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( docPath: string, @@ -298,6 +322,14 @@ export async function updateParticle( }); } +export async function updateStreamStatus( + docPath: string, + status: "open" | "closed", +): Promise { + const particleRef = typedDoc(docPath); + await updateDoc(particleRef, { status, updated_at: serverTimestamp() }); +} + export async function updateStreamPlaybackMarker( docPath: string, humanId: string, -- 2.54.0 From d4b054559875e819c88689b053f6de83bd82301f Mon Sep 17 00:00:00 2001 From: talksik Date: Tue, 7 Apr 2026 15:13:47 -0700 Subject: [PATCH 4/7] ui tweak --- js/src/features/network-root.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/js/src/features/network-root.tsx b/js/src/features/network-root.tsx index a83f8d8..92a1cd8 100644 --- a/js/src/features/network-root.tsx +++ b/js/src/features/network-root.tsx @@ -49,7 +49,7 @@ export default function NetworkRoot() { return (

{/* Top bar — stays in place */} -
+
setStatusTab(v as "open" | "closed")} @@ -77,7 +77,7 @@ export default function NetworkRoot() {
{/* Scrollable content */} -
+
{viewMode === "list" ? ( ) : ( -- 2.54.0 From 4a4e09ae0b866fc18924c375300f64cdc9477abb Mon Sep 17 00:00:00 2001 From: talksik Date: Tue, 7 Apr 2026 15:31:07 -0700 Subject: [PATCH 5/7] show stream state in stream-view --- js/src/features/compose/compose-overlay.tsx | 12 ++++ .../particles/stream-context-menu.tsx | 13 +++- js/src/features/particles/stream-view.tsx | 63 ++++++++++++++++--- 3 files changed, 77 insertions(+), 11 deletions(-) diff --git a/js/src/features/compose/compose-overlay.tsx b/js/src/features/compose/compose-overlay.tsx index 460d7b5..dedc065 100644 --- a/js/src/features/compose/compose-overlay.tsx +++ b/js/src/features/compose/compose-overlay.tsx @@ -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("idle"); const [error, setError] = useState(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; @@ -341,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(); diff --git a/js/src/features/particles/stream-context-menu.tsx b/js/src/features/particles/stream-context-menu.tsx index d065f3b..3bb065f 100644 --- a/js/src/features/particles/stream-context-menu.tsx +++ b/js/src/features/particles/stream-context-menu.tsx @@ -4,6 +4,7 @@ import { 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"; @@ -27,7 +28,17 @@ export function StreamContextMenu({ particle, networkId, children }: StreamConte {children} - {isOpen ? "Close stream" : "Open stream"} + {isOpen ? ( + <> + + Close stream + + ) : ( + <> + + Open stream + + )} diff --git a/js/src/features/particles/stream-view.tsx b/js/src/features/particles/stream-view.tsx index 9f1d19d..3319c1d 100644 --- a/js/src/features/particles/stream-view.tsx +++ b/js/src/features/particles/stream-view.tsx @@ -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"} />
); @@ -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 )} - + {streamParticle.status === "closed" && ( + + + Closed + + )} + + + + + + + { + const docPath = toFirestoreDocPath(particlePath(networkId, [streamParticle.id])); + await updateStreamStatus(docPath, streamParticle.status === "open" ? "closed" : "open"); + }} + > + {streamParticle.status === "open" ? ( + <> + + Close stream + + ) : ( + <> + + Open stream + + )} + + navigate("/settings")}> + + Settings + + +
); } -- 2.54.0 From a9a51f499e081cd1d2f9ca1ebf629d32df64b671 Mon Sep 17 00:00:00 2001 From: talksik Date: Tue, 7 Apr 2026 15:31:10 -0700 Subject: [PATCH 6/7] ui tweaks --- js/src/features/layout.tsx | 2 +- js/src/features/network-root.tsx | 8 ++++---- js/src/features/particles/particle-list-view.tsx | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/js/src/features/layout.tsx b/js/src/features/layout.tsx index b8903b6..ad9996d 100644 --- a/js/src/features/layout.tsx +++ b/js/src/features/layout.tsx @@ -91,7 +91,7 @@ function TopBar() { const { data: particle } = useParticle(path); return ( -
+
diff --git a/js/src/features/network-root.tsx b/js/src/features/network-root.tsx index 92a1cd8..7cc8d54 100644 --- a/js/src/features/network-root.tsx +++ b/js/src/features/network-root.tsx @@ -1,6 +1,6 @@ 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"; @@ -49,14 +49,14 @@ export default function NetworkRoot() { return (
{/* Top bar — stays in place */} -
+
setStatusTab(v as "open" | "closed")} > - Open - Closed + Open + Closed

- No recent streams yet. Start a conversation using the keyboard shortcuts below. + No streams here. Start a conversation using the keyboard shortcuts below.

); -- 2.54.0 From 0ad74d2f8fdb1f3f00091f282513c553d6974ab1 Mon Sep 17 00:00:00 2001 From: talksik Date: Tue, 7 Apr 2026 16:10:53 -0700 Subject: [PATCH 7/7] cleanup message retention from orion api --- go/cmd/orion/main.go | 1 - go/internal/handler/handler.go | 76 ------------------- go/internal/network/models.go | 3 - go/internal/network/repository.go | 27 ++----- go/internal/network/service.go | 13 ---- .../000011_message_retention.down.sql | 2 - go/migrations/000011_message_retention.up.sql | 2 - .../000011_remove_network_capacity.down.sql | 3 + .../000011_remove_network_capacity.up.sql | 3 + 9 files changed, 12 insertions(+), 118 deletions(-) delete mode 100644 go/migrations/000011_message_retention.down.sql delete mode 100644 go/migrations/000011_message_retention.up.sql create mode 100644 go/migrations/000011_remove_network_capacity.down.sql create mode 100644 go/migrations/000011_remove_network_capacity.up.sql diff --git a/go/cmd/orion/main.go b/go/cmd/orion/main.go index abb7f5b..25fd578 100644 --- a/go/cmd/orion/main.go +++ b/go/cmd/orion/main.go @@ -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 diff --git a/go/internal/handler/handler.go b/go/internal/handler/handler.go index 1e9e95f..45f9077 100644 --- a/go/internal/handler/handler.go +++ b/go/internal/handler/handler.go @@ -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 } diff --git a/go/internal/network/models.go b/go/internal/network/models.go index 7753400..a0906b1 100644 --- a/go/internal/network/models.go +++ b/go/internal/network/models.go @@ -7,9 +7,6 @@ type Network struct { Name string AdminHumanId string MemberHumanIds []string - OpenStreamCapacity int - OpenStreamCount int - MessageRetentionHours int CreatedAt time.Time } diff --git a/go/internal/network/repository.go b/go/internal/network/repository.go index 6c4fb6b..b39bae3 100644 --- a/go/internal/network/repository.go +++ b/go/internal/network/repository.go @@ -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 -} diff --git a/go/internal/network/service.go b/go/internal/network/service.go index cefae1f..8a4ecbb 100644 --- a/go/internal/network/service.go +++ b/go/internal/network/service.go @@ -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 (24–336 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 { diff --git a/go/migrations/000011_message_retention.down.sql b/go/migrations/000011_message_retention.down.sql deleted file mode 100644 index fb13dc1..0000000 --- a/go/migrations/000011_message_retention.down.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE networks - DROP COLUMN IF EXISTS message_retention_hours; diff --git a/go/migrations/000011_message_retention.up.sql b/go/migrations/000011_message_retention.up.sql deleted file mode 100644 index 765cb14..0000000 --- a/go/migrations/000011_message_retention.up.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE networks - ADD COLUMN message_retention_hours INTEGER NOT NULL DEFAULT 24; diff --git a/go/migrations/000011_remove_network_capacity.down.sql b/go/migrations/000011_remove_network_capacity.down.sql new file mode 100644 index 0000000..60f848b --- /dev/null +++ b/go/migrations/000011_remove_network_capacity.down.sql @@ -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; diff --git a/go/migrations/000011_remove_network_capacity.up.sql b/go/migrations/000011_remove_network_capacity.up.sql new file mode 100644 index 0000000..87e9857 --- /dev/null +++ b/go/migrations/000011_remove_network_capacity.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE networks + DROP COLUMN IF EXISTS open_stream_capacity, + DROP COLUMN IF EXISTS open_stream_count; -- 2.54.0