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)} + /> +
); } +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}`); +}