diff --git a/js/src/features/particles/stream-top-bar.tsx b/js/src/features/particles/stream-top-bar.tsx
new file mode 100644
index 0000000..3d1c7d2
--- /dev/null
+++ b/js/src/features/particles/stream-top-bar.tsx
@@ -0,0 +1,278 @@
+import { useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { useAuthStore } from "@/stores/auth-store";
+import { apiClient } from "@/api/client";
+import type { Particle } from "@/api/types";
+import { particlePath, toFirestoreDocPath } from "@/lib/particle-path";
+import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { useNetwork } from "@/hooks/use-networks";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Settings, CircleCheckBig, CircleDot, EllipsisVertical, Pencil, Globe } from "lucide-react";
+import { updateStreamStatus } from "@/lib/firestore-particles";
+import { RenameStreamOverlay } from "@/features/particles/rename-stream-overlay";
+import { StreamMembersOverlay } from "@/features/particles/stream-members-overlay";
+import { parseVisibleTo } from "@/lib/stream-visibility";
+import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb";
+import { WindowControls } from "@/components/window-controls";
+import { RelativeTimestamp } from "@/components/relative-timestamp";
+import { useStreamPresence } from "@/features/particles/stream-presence-context";
+import { getInitials } from "@/lib/utils";
+
+function getParticleDisplayName(particle: Particle): string {
+ switch (particle.type) {
+ case "stream":
+ case "folder":
+ return particle.properties.name;
+ case "quest":
+ return particle.properties.title;
+ case "paper":
+ return particle.properties.title;
+ case "file":
+ return particle.properties.filename;
+ case "text":
+ return particle.properties.content.slice(0, 30);
+ case "media":
+ return particle.type;
+ }
+}
+
+interface TopBarProps {
+ networkId: string;
+ particle: Particle | null;
+ streamParticle: Particle & { type: "stream" };
+}
+
+export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
+ const navigate = useNavigate();
+ const network = useNetwork(networkId);
+ const userId = useAuthStore((s) => s.user?.id);
+ const isCreator = !!userId && userId === streamParticle.created_by_human_id;
+ const [renameOpen, setRenameOpen] = useState(false);
+ const [membersOpen, setMembersOpen] = useState(false);
+
+ const huddleParticipants = streamParticle.huddle_active_participants ?? [];
+ const hasActiveHuddle = huddleParticipants.length > 0;
+
+ const handleJoinHuddle = () => {
+ apiClient.getLivekitToken(networkId, streamParticle.id).then(({ token, server_url }) => {
+ window.electronWindow.openHuddle({ token, serverUrl: server_url });
+ });
+ };
+
+ return (
+
+
+
+
+
+ {streamParticle && (
+ <>
+
+ {getParticleDisplayName(streamParticle)}
+
+ >
+ )}
+
+ {particle && (
+ <>
+
+
+
+
+ >
+ )}
+
+
+
+ {hasActiveHuddle && (
+
+ )}
+
+ {streamParticle.status === "closed" && (
+
+
+ Closed
+
+ )}
+
+
setMembersOpen(true)}
+ />
+
+
+
+
+
+
+ {
+ const docPath = toFirestoreDocPath(particlePath(networkId, [streamParticle.id]));
+ await updateStreamStatus(docPath, streamParticle.status === "open" ? "closed" : "open");
+ }}
+ >
+ {streamParticle.status === "open" ? (
+ <>
+
+ Close stream
+ >
+ ) : (
+ <>
+
+ Open stream
+ >
+ )}
+
+ {isCreator && (
+ setRenameOpen(true)}>
+
+ Rename stream
+
+ )}
+ navigate("/settings")}>
+
+ Settings
+
+
+
+
+ {renameOpen && isCreator && (
+ setRenameOpen(false)}
+ />
+ )}
+
+ {membersOpen && (
+ setMembersOpen(false)}
+ />
+ )}
+
+ );
+}
+
+function MembersIndicator({
+ networkId,
+ streamParticle,
+ onClick,
+}: {
+ networkId: string;
+ streamParticle: Particle & { type: "stream" };
+ onClick: () => void;
+}) {
+ const network = useNetwork(networkId);
+ const visibility = parseVisibleTo(streamParticle.visible_to, networkId);
+ const humans = network?.humans ?? [];
+
+ const memberIds =
+ visibility.mode === "network"
+ ? humans.map((h) => h.id)
+ : visibility.humanIds;
+ const shownMembers = memberIds
+ .slice(0, 3)
+ .map((id) => humans.find((h) => h.id === id))
+ .filter((h): h is NonNullable => !!h);
+ const overflow = memberIds.length - shownMembers.length;
+
+ return (
+
+
+
+
+
+ {visibility.mode === "network"
+ ? `Everyone in ${network?.name ?? "network"}`
+ : `${memberIds.length} ${memberIds.length === 1 ? "member" : "members"}`}
+
+
+ );
+}
+
+function ParticleBreadcrumbContent({ particle, networkId }: { particle: Particle; networkId: string }) {
+ const network = useNetwork(networkId);
+ const { onlineHumanIds } = useStreamPresence();
+ const creator = network?.humans?.find((h) => h.id === particle.created_by_human_id);
+ const prefix = creator?.email_prefix ?? particle.created_by_human_id;
+ const initials = prefix.slice(0, 2).toUpperCase();
+ const isOnline = particle.created_by_human_id ? onlineHumanIds.has(particle.created_by_human_id) : false;
+
+ return (
+
+
+
+ {initials}
+
+
+ {prefix} -
+
+ );
+}
diff --git a/js/src/features/particles/stream-view.tsx b/js/src/features/particles/stream-view.tsx
index 7c8f3db..e57d545 100644
--- a/js/src/features/particles/stream-view.tsx
+++ b/js/src/features/particles/stream-view.tsx
@@ -9,34 +9,19 @@ import { PlaybackPageIndicator } from "@/features/particles/playback-page-indica
import { MediaParticleView, type MediaParticleHandle } from "@/features/particles/media-particle-view";
import { TextParticleView } from "@/features/particles/text-particle-view";
import { FallbackParticleView } from "@/features/particles/fallback-particle-view";
-import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
import { VideoAudioToggle } from "@/components/video-audio-toggle";
import { useMediaSettingsStore } from "@/stores/media-settings-store";
import { KeybindingsOverlay, type KeybindingGroup } from "@/components/keybindings-overlay";
-import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
-import { useNetwork, useNetworks } from "@/hooks/use-networks";
-import { Button } from "@/components/ui/button";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-import { Settings, CircleCheckBig, CircleDot, EllipsisVertical, Pencil, Globe } from "lucide-react";
-import { updateStreamStatus, toggleParticleReaction } from "@/lib/firestore-particles";
-import { RenameStreamOverlay } from "@/features/particles/rename-stream-overlay";
-import { StreamMembersOverlay } from "@/features/particles/stream-members-overlay";
-import { parseVisibleTo } from "@/lib/stream-visibility";
+import { useNetwork } from "@/hooks/use-networks";
+import { toggleParticleReaction } from "@/lib/firestore-particles";
import { ReactionBar } from "@/features/particles/reaction-bar";
-import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb";
-import { WindowControls } from "@/components/window-controls";
-import { RelativeTimestamp } from "@/components/relative-timestamp";
+import { TopBar } from "@/features/particles/stream-top-bar";
import { useStreamPlayback } from "@/hooks/use-stream-playback";
import { usePrefetchAdjacentMedia } from "@/hooks/use-prefetch-adjacent-media";
import { usePresencePositions } from "@/hooks/use-presence-positions";
import { StreamPresenceProvider, useStreamPresence, useStreamComposing, useStreamComposingBroadcast, type ComposingMode } from "@/features/particles/stream-presence-context";
import { ComposingIndicator } from "@/components/composing-indicator";
-import { cn, getInitials } from "@/lib/utils";
+import { cn } from "@/lib/utils";
import { useMount } from "react-use";
function getReactions(particle: Particle): Record | undefined {
@@ -44,24 +29,6 @@ function getReactions(particle: Particle): Record | undefined
return undefined;
}
-function getParticleDisplayName(particle: Particle): string {
- switch (particle.type) {
- case "stream":
- case "folder":
- return particle.properties.name;
- case "quest":
- return particle.properties.title;
- case "paper":
- return particle.properties.title;
- case "file":
- return particle.properties.filename;
- case "text":
- return particle.properties.content.slice(0, 30);
- case "media":
- return particle.type;
- }
-}
-
// --- Exit countdown hook ---
const EXIT_DELAY_MS = 5000;
@@ -623,250 +590,3 @@ function StreamViewControls({
);
}
-
-function TopBar({ networkId, particle, streamParticle }: { networkId: string; particle: Particle | null; streamParticle: Particle & { type: "stream" } }) {
- const navigate = useNavigate();
- const network = useNetwork(networkId);
- const userId = useAuthStore((s) => s.user?.id);
- const isCreator = !!userId && userId === streamParticle.created_by_human_id;
- const [renameOpen, setRenameOpen] = useState(false);
- const [membersOpen, setMembersOpen] = useState(false);
-
- const huddleParticipants = streamParticle.huddle_active_participants ?? [];
- const hasActiveHuddle = huddleParticipants.length > 0;
-
- const handleJoinHuddle = () => {
- apiClient.getLivekitToken(networkId, streamParticle.id).then(({ token, server_url }) => {
- window.electronWindow.openHuddle({ token, serverUrl: server_url });
- });
- };
-
- return (
-
-
-
-
-
- {streamParticle && (
- <>
-
- {getParticleDisplayName(streamParticle)}
-
- >
- )}
-
- {particle && (
- <>
-
-
-
-
- >
- )}
-
-
-
- {hasActiveHuddle && (
-
- )}
-
- {streamParticle.status === "closed" && (
-
-
- Closed
-
- )}
-
-
setMembersOpen(true)}
- />
-
-
-
-
-
-
- {
- const docPath = toFirestoreDocPath(particlePath(networkId, [streamParticle.id]));
- await updateStreamStatus(docPath, streamParticle.status === "open" ? "closed" : "open");
- }}
- >
- {streamParticle.status === "open" ? (
- <>
-
- Close stream
- >
- ) : (
- <>
-
- Open stream
- >
- )}
-
- {isCreator && (
- setRenameOpen(true)}>
-
- Rename stream
-
- )}
- navigate("/settings")}>
-
- Settings
-
-
-
-
- {renameOpen && isCreator && (
- setRenameOpen(false)}
- />
- )}
-
- {membersOpen && (
- setMembersOpen(false)}
- />
- )}
-
- );
-}
-
-function MembersIndicator({
- networkId,
- streamParticle,
- onClick,
-}: {
- networkId: string;
- streamParticle: Particle & { type: "stream" };
- onClick: () => void;
-}) {
- const network = useNetwork(networkId);
- const visibility = parseVisibleTo(streamParticle.visible_to, networkId);
- const humans = network?.humans ?? [];
-
- const memberIds =
- visibility.mode === "network"
- ? humans.map((h) => h.id)
- : visibility.humanIds;
- const shownMembers = memberIds
- .slice(0, 3)
- .map((id) => humans.find((h) => h.id === id))
- .filter((h): h is NonNullable => !!h);
- const overflow = memberIds.length - shownMembers.length;
-
- return (
-
-
-
-
-
- {visibility.mode === "network"
- ? `Everyone in ${network?.name ?? "network"}`
- : `${memberIds.length} ${memberIds.length === 1 ? "member" : "members"}`}
-
-
- );
-}
-
-function NetworkBreadcrumbContent({ networkId }: { networkId: string }) {
- const { data: networks } = useNetworks();
- const network = networks?.find((n) => n.id === networkId);
- const name = network?.name ?? networkId;
- const initials = name.slice(0, 2).toUpperCase();
-
- return (
-
-
-
- {initials}
-
-
- {name}
-
- );
-}
-
-function ParticleBreadcrumbContent({ particle, networkId }: { particle: Particle; networkId: string }) {
- const network = useNetwork(networkId);
- const { onlineHumanIds } = useStreamPresence();
- const creator = network?.humans?.find((h) => h.id === particle.created_by_human_id);
- const prefix = creator?.email_prefix ?? particle.created_by_human_id;
- const initials = prefix.slice(0, 2).toUpperCase();
- const isOnline = particle.created_by_human_id ? onlineHumanIds.has(particle.created_by_human_id) : false;
-
- return (
-
-
-
- {initials}
-
-
- {prefix} -
-
- )
-}
-