diff --git a/js/src/features/particles/rename-stream-overlay.tsx b/js/src/features/particles/rename-stream-overlay.tsx
new file mode 100644
index 0000000..456c69b
--- /dev/null
+++ b/js/src/features/particles/rename-stream-overlay.tsx
@@ -0,0 +1,98 @@
+import { useCallback, useEffect, useState } from "react";
+import { createPortal } from "react-dom";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import { updateParticleProperties } from "@/lib/firestore-particles";
+import { particlePath, toFirestoreDocPath } from "@/lib/particle-path";
+import type { Particle } from "@/api/types";
+
+interface RenameStreamOverlayProps {
+ networkId: string;
+ streamParticle: Particle & { type: "stream" };
+ onClose: () => void;
+}
+
+export function RenameStreamOverlay({
+ networkId,
+ streamParticle,
+ onClose,
+}: RenameStreamOverlayProps) {
+ const [name, setName] = useState(streamParticle.properties.name);
+ const [saving, setSaving] = useState(false);
+
+ const trimmed = name.trim();
+ const canSave =
+ !saving &&
+ trimmed.length > 0 &&
+ trimmed !== streamParticle.properties.name;
+
+ const handleSave = useCallback(async () => {
+ if (!canSave) return;
+ setSaving(true);
+ try {
+ const docPath = toFirestoreDocPath(particlePath(networkId, [streamParticle.id]));
+ await updateParticleProperties<"stream">(docPath, { name: trimmed });
+ onClose();
+ } finally {
+ setSaving(false);
+ }
+ }, [canSave, networkId, onClose, streamParticle.id, trimmed]);
+
+ useEffect(() => {
+ const handler = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ e.preventDefault();
+ e.stopPropagation();
+ onClose();
+ }
+ };
+ window.addEventListener("keydown", handler, { capture: true });
+ return () => window.removeEventListener("keydown", handler, { capture: true });
+ }, [onClose]);
+
+ return createPortal(
+
+
+
+
+
Rename stream
+
+
+ Esc
+ {" "}
+ to close
+
+
+
+
setName(e.target.value)}
+ onFocus={(e) => e.currentTarget.select()}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ handleSave();
+ }
+ }}
+ placeholder="Stream name"
+ className="border-white/10 bg-white/5 text-white placeholder-white/30 focus-visible:border-white/30 focus-visible:ring-0"
+ />
+
+
+
+
+
+
+
,
+ document.body,
+ );
+}
diff --git a/js/src/features/particles/stream-members-overlay.tsx b/js/src/features/particles/stream-members-overlay.tsx
new file mode 100644
index 0000000..c2e7652
--- /dev/null
+++ b/js/src/features/particles/stream-members-overlay.tsx
@@ -0,0 +1,263 @@
+import { useCallback, useEffect, useMemo } from "react";
+import { createPortal } from "react-dom";
+import { X, UserPlus, Globe, Users, Lock } from "lucide-react";
+import { Avatar, AvatarFallback } from "@/components/ui/avatar";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import {
+ buildCustomVisibility,
+ buildNetworkVisibility,
+ parseVisibleTo,
+} from "@/lib/stream-visibility";
+import { updateParticleVisibleTo } from "@/lib/firestore-particles";
+import { particlePath, toFirestoreDocPath } from "@/lib/particle-path";
+import { useNetwork } from "@/hooks/use-networks";
+import { cn, getInitials } from "@/lib/utils";
+import type { Particle } from "@/api/types";
+
+interface StreamMembersOverlayProps {
+ networkId: string;
+ streamParticle: Particle & { type: "stream" };
+ isCreator: boolean;
+ onClose: () => void;
+}
+
+export function StreamMembersOverlay({
+ networkId,
+ streamParticle,
+ isCreator,
+ onClose,
+}: StreamMembersOverlayProps) {
+ const network = useNetwork(networkId);
+ const humans = network?.humans ?? [];
+ const creatorId = streamParticle.created_by_human_id;
+ const visibility = parseVisibleTo(streamParticle.visible_to, networkId);
+
+ const docPath = useMemo(
+ () => toFirestoreDocPath(particlePath(networkId, [streamParticle.id])),
+ [networkId, streamParticle.id],
+ );
+
+ const memberIds =
+ visibility.mode === "network"
+ ? humans.map((h) => h.id)
+ : visibility.humanIds;
+ const memberSet = new Set(memberIds);
+ const availableToAdd = humans.filter((h) => !memberSet.has(h.id));
+
+ const setNetworkWide = useCallback(() => {
+ void updateParticleVisibleTo(docPath, buildNetworkVisibility(networkId));
+ }, [docPath, networkId]);
+
+ const setCustomOnlyCreator = useCallback(() => {
+ void updateParticleVisibleTo(docPath, buildCustomVisibility([creatorId]));
+ }, [docPath, creatorId]);
+
+ const removeMember = useCallback(
+ (id: string) => {
+ if (visibility.mode !== "custom") return;
+ if (id === creatorId) return;
+ const next = visibility.humanIds.filter((x) => x !== id);
+ if (next.length === 0) return;
+ void updateParticleVisibleTo(docPath, buildCustomVisibility(next));
+ },
+ [docPath, creatorId, visibility],
+ );
+
+ const addMember = useCallback(
+ (id: string) => {
+ if (visibility.mode !== "custom") return;
+ void updateParticleVisibleTo(
+ docPath,
+ buildCustomVisibility([...visibility.humanIds, id]),
+ );
+ },
+ [docPath, visibility],
+ );
+
+ useEffect(() => {
+ const handler = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ e.preventDefault();
+ e.stopPropagation();
+ onClose();
+ }
+ };
+ window.addEventListener("keydown", handler, { capture: true });
+ return () => window.removeEventListener("keydown", handler, { capture: true });
+ }, [onClose]);
+
+ return createPortal(
+
+
+
+ {/* Header */}
+
+
Members
+
+
+ Esc
+ {" "}
+ to close
+
+
+
+ {/* Visibility */}
+
+
+ Visibility
+
+ {isCreator ? (
+
+ }
+ label="Network-wide"
+ onClick={setNetworkWide}
+ />
+ }
+ label="Specific people"
+ onClick={setCustomOnlyCreator}
+ />
+
+ ) : (
+
+ {visibility.mode === "network" ? (
+ <>
+
+ Everyone in {network?.name ?? "network"}
+ >
+ ) : (
+ <>
+
+ {memberIds.length} specific people
+ >
+ )}
+
+ )}
+
+
+ {/* Member list */}
+
+
+ {visibility.mode === "network" ? "Has access" : "People"}{" "}
+ {memberIds.length}
+
+
+
+ {memberIds.map((id) => {
+ const human = humans.find((h) => h.id === id);
+ const isCreatorRow = id === creatorId;
+ const canRemove =
+ isCreator && visibility.mode === "custom" && !isCreatorRow;
+ return (
+ -
+
+
+ {human ? getInitials(human.email) : "?"}
+
+
+
+ {human?.email_prefix ?? id}
+
+ {isCreatorRow && (
+
+ Creator
+
+ )}
+ {canRemove && (
+
+ )}
+
+ );
+ })}
+
+
+
+
+ {/* Add */}
+ {isCreator && visibility.mode === "custom" && availableToAdd.length > 0 && (
+
+
+
+ Add people
+
+
+
+ {availableToAdd.map((human) => (
+ -
+
+
+ ))}
+
+
+
+ )}
+
+ {isCreator && visibility.mode === "custom" && availableToAdd.length === 0 && (
+
+
+ Everyone in the network is already a member
+
+ )}
+
+
,
+ document.body,
+ );
+}
+
+function VisibilityPill({
+ active,
+ icon,
+ label,
+ onClick,
+}: {
+ active: boolean;
+ icon: React.ReactNode;
+ label: string;
+ onClick: () => void;
+}) {
+ return (
+
+ );
+}
diff --git a/js/src/features/particles/stream-view.tsx b/js/src/features/particles/stream-view.tsx
index 5456e4f..7c8f3db 100644
--- a/js/src/features/particles/stream-view.tsx
+++ b/js/src/features/particles/stream-view.tsx
@@ -22,8 +22,11 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
-import { Settings, CircleCheckBig, CircleDot, EllipsisVertical } from "lucide-react";
+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 { ReactionBar } from "@/features/particles/reaction-bar";
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb";
import { WindowControls } from "@/components/window-controls";
@@ -624,6 +627,10 @@ 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;
@@ -697,6 +704,12 @@ function TopBar({ networkId, particle, streamParticle }: { networkId: string; pa
)}
+ setMembersOpen(true)}
+ />
+
)}
+ {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);
diff --git a/js/src/lib/stream-visibility.ts b/js/src/lib/stream-visibility.ts
new file mode 100644
index 0000000..c748769
--- /dev/null
+++ b/js/src/lib/stream-visibility.ts
@@ -0,0 +1,29 @@
+import { removeDuplicates } from "@/lib/utils";
+
+const HUMAN_PREFIX = "human:";
+const NETWORK_PREFIX = "network:";
+
+export type StreamVisibility =
+ | { mode: "network" }
+ | { mode: "custom"; humanIds: string[] };
+
+export function parseVisibleTo(
+ visibleTo: string[],
+ networkId: string,
+): StreamVisibility {
+ if (visibleTo.includes(`${NETWORK_PREFIX}${networkId}`)) {
+ return { mode: "network" };
+ }
+ const humanIds = visibleTo
+ .filter((v) => v.startsWith(HUMAN_PREFIX))
+ .map((v) => v.slice(HUMAN_PREFIX.length));
+ return { mode: "custom", humanIds };
+}
+
+export function buildNetworkVisibility(networkId: string): string[] {
+ return [`${NETWORK_PREFIX}${networkId}`];
+}
+
+export function buildCustomVisibility(humanIds: string[]): string[] {
+ return removeDuplicates(humanIds).map((id) => `${HUMAN_PREFIX}${id}`);
+}