484ee9d4fb
Added scroll to longer content and padding to avoid conflicting with stream view peripheral controls and panels
777 lines
25 KiB
TypeScript
777 lines
25 KiB
TypeScript
import { useState, useEffect, useEffectEvent, useCallback, useRef } from "react";
|
||
import { useNavigate } from "react-router-dom";
|
||
import { useAuthStore } from "@/stores/auth-store";
|
||
import { apiClient } from "@/api/client";
|
||
import { type Particle, REACTION_EMOJIS } from "@/api/types";
|
||
import { parseParticlePath, particlePath, toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path";
|
||
import { ComposeOverlay, type ComposeStep } from "@/features/compose/compose-overlay";
|
||
import { PlaybackPageIndicator } from "@/features/particles/playback-page-indicator";
|
||
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 } from "lucide-react";
|
||
import { updateStreamStatus, 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 { 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 { useMount } from "react-use";
|
||
|
||
function getReactions(particle: Particle): Record<string, string[]> | undefined {
|
||
if (particle.type === "media" || particle.type === "text") return particle.reactions;
|
||
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;
|
||
const EXIT_TICK_MS = 100;
|
||
|
||
type PlaybackStatus = "idle" | "playing" | "ended";
|
||
|
||
function useExitCountdown(
|
||
status: PlaybackStatus,
|
||
composeActive: boolean,
|
||
onExit: () => void,
|
||
) {
|
||
const [remainingMs, setRemainingMs] = useState<number | null>(null);
|
||
|
||
const handleExit = useEffectEvent(() => {
|
||
onExit();
|
||
});
|
||
|
||
// Start/cancel countdown based on playback status
|
||
useEffect(() => {
|
||
if (status === "ended") {
|
||
setRemainingMs(EXIT_DELAY_MS);
|
||
} else {
|
||
setRemainingMs(null);
|
||
}
|
||
}, [status]);
|
||
|
||
// Tick the countdown down (pauses when compose is active)
|
||
useEffect(() => {
|
||
if (remainingMs === null || remainingMs <= 0 || composeActive) return;
|
||
|
||
const interval = setInterval(() => {
|
||
setRemainingMs((prev) => {
|
||
if (prev === null) return null;
|
||
const next = prev - EXIT_TICK_MS;
|
||
return next <= 0 ? 0 : next;
|
||
});
|
||
}, EXIT_TICK_MS);
|
||
|
||
return () => clearInterval(interval);
|
||
}, [remainingMs !== null && remainingMs > 0, composeActive]);
|
||
|
||
// Navigate once countdown hits zero
|
||
useEffect(() => {
|
||
if (remainingMs !== null && remainingMs <= 0) {
|
||
handleExit();
|
||
}
|
||
}, [remainingMs]);
|
||
|
||
return remainingMs;
|
||
}
|
||
|
||
// --- Keybindings ---
|
||
|
||
const STREAM_VIEW_KEYBINDINGS: KeybindingGroup[] = [
|
||
{
|
||
label: "Navigation",
|
||
bindings: [
|
||
{ keys: ["←", "→", "↑", "↓"], description: "Previous / next particle" },
|
||
{ keys: ["Esc"], description: "Back to network" },
|
||
],
|
||
},
|
||
{
|
||
label: "Playback",
|
||
bindings: [
|
||
{ keys: ["Hold", "Space"], description: "Pause" },
|
||
{ keys: ["Hold", "Shift"], description: "1.5× speed" },
|
||
],
|
||
},
|
||
{
|
||
label: "Compose",
|
||
bindings: [
|
||
{ keys: ["Hold", "`"], description: "Reply" },
|
||
{ keys: ["S"], description: "Screen record" },
|
||
{ keys: ["T"], description: "Text compose" },
|
||
{ keys: ["V"], description: "Toggle video / audio" },
|
||
{ keys: ["H"], description: "Join huddle" },
|
||
],
|
||
},
|
||
{
|
||
label: "Reactions",
|
||
bindings: [
|
||
{ keys: ["1-7"], description: "Toggle emoji reaction" },
|
||
],
|
||
},
|
||
];
|
||
|
||
// --- StreamView ---
|
||
|
||
interface StreamViewProps {
|
||
streamParticle: Particle & { type: "stream" };
|
||
path: ParticlePath;
|
||
}
|
||
|
||
export function StreamView({ path, streamParticle }: StreamViewProps) {
|
||
const { networkId } = parseParticlePath(path);
|
||
|
||
return (
|
||
<StreamPresenceProvider networkId={networkId} streamId={streamParticle.id}>
|
||
<StreamViewInner path={path} streamParticle={streamParticle} />
|
||
</StreamPresenceProvider>
|
||
);
|
||
}
|
||
|
||
function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||
const { networkId } = parseParticlePath(path);
|
||
const navigate = useNavigate();
|
||
|
||
useMount(() => {
|
||
window.electronAutoplay.dismiss();
|
||
});
|
||
|
||
const {
|
||
children,
|
||
currentParticle,
|
||
currentIndex,
|
||
status,
|
||
paused,
|
||
next,
|
||
prev,
|
||
goTo,
|
||
pause,
|
||
resume,
|
||
} = useStreamPlayback(streamParticle, path);
|
||
|
||
usePrefetchAdjacentMedia(children, currentIndex);
|
||
|
||
const authedUser = useAuthStore((s) => s.user);
|
||
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
|
||
const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode);
|
||
const network = useNetwork(networkId);
|
||
const presenceBySegment = usePresencePositions(
|
||
streamParticle.playback_markers,
|
||
children,
|
||
network?.humans,
|
||
authedUser?.id,
|
||
);
|
||
|
||
// --- Stream presence (realtime via pusher) ---
|
||
const { onlineHumanIds } = useStreamPresence();
|
||
const { composingUsers } = useStreamComposing();
|
||
const { startComposing, stopComposing } = useStreamComposingBroadcast();
|
||
|
||
const mediaRef = useRef<MediaParticleHandle>(null);
|
||
|
||
const handleToggleReaction = useCallback((emoji: string) => {
|
||
if (!authedUser || !currentParticle) return;
|
||
|
||
|
||
const currentParticleDocPath = currentParticle
|
||
? toFirestoreDocPath(particlePath(networkId, [streamParticle.id, currentParticle.id]))
|
||
: null;
|
||
if (!currentParticleDocPath) return;
|
||
|
||
const reactions = getReactions(currentParticle);
|
||
toggleParticleReaction(currentParticleDocPath, emoji, authedUser.id, reactions);
|
||
}, [authedUser, currentParticle]);
|
||
|
||
const [composeActive, setComposeActive] = useState(false);
|
||
const [composeStep, setComposeStep] = useState<ComposeStep>("idle");
|
||
const [progress, setProgress] = useState(0);
|
||
const [fastPlayback, setFastPlayback] = useState(false);
|
||
const [showKeybindings, setShowKeybindings] = useState(false);
|
||
|
||
// Broadcast composing state to other viewers
|
||
useEffect(() => {
|
||
const stepToMode: Record<string, ComposingMode | null> = {
|
||
idle: null,
|
||
submitting: null,
|
||
recording: "recording",
|
||
typing: "typing",
|
||
reviewing: "typing",
|
||
configuring: "typing",
|
||
picking: "screen",
|
||
};
|
||
const mode = stepToMode[composeStep] ?? null;
|
||
if (mode) {
|
||
startComposing(mode);
|
||
} else {
|
||
stopComposing();
|
||
}
|
||
}, [composeStep, startComposing, stopComposing]);
|
||
|
||
// Show/hide chrome on mouse activity (YouTube-style)
|
||
const [showControls, setShowControls] = useState(true);
|
||
const idleTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||
const handleMouseActivity = useCallback(() => {
|
||
setShowControls(true);
|
||
clearTimeout(idleTimerRef.current);
|
||
idleTimerRef.current = setTimeout(() => setShowControls(false), 3000);
|
||
}, []);
|
||
useEffect(() => () => clearTimeout(idleTimerRef.current), []);
|
||
|
||
// Always show controls when compose is active or exit countdown is visible
|
||
const controlsVisible = showControls || composeActive || status === "ended";
|
||
|
||
const handleExitNavigate = useCallback(() => {
|
||
navigate(`/${networkId}`);
|
||
}, [navigate, networkId]);
|
||
|
||
const exitRemainingMs = useExitCountdown(
|
||
status,
|
||
composeActive,
|
||
handleExitNavigate,
|
||
);
|
||
|
||
// Reset progress when particle changes
|
||
useEffect(() => {
|
||
setProgress(0);
|
||
}, [currentParticle?.id]);
|
||
|
||
// Pause/resume playback when compose overlay opens/closes
|
||
useEffect(() => {
|
||
if (composeActive) pause();
|
||
else resume();
|
||
}, [composeActive, pause, resume]);
|
||
|
||
// Playback keyboard: arrows, escape, hold-space-to-pause
|
||
useEffect(() => {
|
||
function isInputTarget(e: KeyboardEvent) {
|
||
const target = e.target as HTMLElement;
|
||
return (
|
||
target.tagName === "INPUT" ||
|
||
target.tagName === "TEXTAREA" ||
|
||
target.isContentEditable
|
||
);
|
||
}
|
||
|
||
const handleKeyDown = (e: KeyboardEvent) => {
|
||
if (composeActive) return;
|
||
if (isInputTarget(e)) return;
|
||
|
||
switch (e.key) {
|
||
case "ArrowRight":
|
||
case "ArrowDown":
|
||
e.preventDefault();
|
||
next();
|
||
break;
|
||
case "ArrowLeft":
|
||
case "ArrowUp":
|
||
e.preventDefault();
|
||
prev();
|
||
break;
|
||
case " ":
|
||
e.preventDefault();
|
||
if (!e.repeat) pause();
|
||
break;
|
||
case "Shift":
|
||
if (!e.repeat) {
|
||
mediaRef.current?.setPlaybackRate(1.5);
|
||
setFastPlayback(true);
|
||
}
|
||
break;
|
||
case "Escape":
|
||
e.preventDefault();
|
||
navigate(`/${networkId}`);
|
||
break;
|
||
case "h": {
|
||
e.preventDefault();
|
||
apiClient.getLivekitToken(networkId, streamParticle.id).then(({ token, server_url }) => {
|
||
window.electronWindow.openHuddle({ token, serverUrl: server_url });
|
||
});
|
||
navigate(`/${networkId}`);
|
||
break;
|
||
}
|
||
case "v":
|
||
e.preventDefault();
|
||
setRecordingMode(recordingMode === "video" ? "audio" : "video");
|
||
break;
|
||
case "?":
|
||
e.preventDefault();
|
||
setShowKeybindings((v) => !v);
|
||
break;
|
||
case "1": case "2": case "3": case "4": case "5": case "6": case "7":
|
||
e.preventDefault();
|
||
handleToggleReaction(REACTION_EMOJIS[parseInt(e.key) - 1]);
|
||
break;
|
||
}
|
||
};
|
||
|
||
const handleKeyUp = (e: KeyboardEvent) => {
|
||
if (composeActive) return;
|
||
if (isInputTarget(e)) return;
|
||
|
||
if (e.key === " ") {
|
||
e.preventDefault();
|
||
resume();
|
||
}
|
||
if (e.key === "Shift") {
|
||
mediaRef.current?.setPlaybackRate(1);
|
||
setFastPlayback(false);
|
||
}
|
||
};
|
||
|
||
window.addEventListener("keydown", handleKeyDown);
|
||
window.addEventListener("keyup", handleKeyUp);
|
||
return () => {
|
||
window.removeEventListener("keydown", handleKeyDown);
|
||
window.removeEventListener("keyup", handleKeyUp);
|
||
};
|
||
},
|
||
[composeActive, next, prev, pause, resume, navigate, networkId, streamParticle.id, setShowKeybindings, handleToggleReaction, recordingMode, setRecordingMode],
|
||
);
|
||
|
||
// Click-to-navigate: left 30% = prev, right 70% = next
|
||
// Suppressed when the user has selected text (drag-to-select)
|
||
const handlePlaybackClick = useCallback(
|
||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||
const selection = window.getSelection();
|
||
if (selection && selection.toString().length > 0) return;
|
||
|
||
const rect = e.currentTarget.getBoundingClientRect();
|
||
const x = (e.clientX - rect.left) / rect.width;
|
||
if (x < 0.3) prev();
|
||
else if (x > 0.7) next();
|
||
},
|
||
[prev, next],
|
||
);
|
||
|
||
if (children.length === 0) {
|
||
return (
|
||
<div className="flex h-full flex-col items-center justify-center gap-4 bg-black text-white">
|
||
<p className="text-muted-foreground text-sm">
|
||
No particles in this stream yet
|
||
</p>
|
||
<StreamViewControls
|
||
showEscape
|
||
onOpenKeybindings={() => setShowKeybindings(true)}
|
||
/>
|
||
<ComposeOverlay
|
||
networkId={networkId}
|
||
targetPath={path}
|
||
onActiveChange={setComposeActive}
|
||
disabled={streamParticle.status === "closed"}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Render particle content inline
|
||
function renderParticle(particle: Particle) {
|
||
switch (particle.type) {
|
||
case "media":
|
||
return (
|
||
<MediaParticleView
|
||
ref={mediaRef}
|
||
key={particle.id}
|
||
particle={particle}
|
||
streamPath={path}
|
||
paused={paused}
|
||
onEnded={next}
|
||
onProgress={setProgress}
|
||
/>
|
||
);
|
||
case "text":
|
||
return (
|
||
<TextParticleView
|
||
key={particle.id}
|
||
particle={particle}
|
||
streamPath={path}
|
||
paused={paused}
|
||
onEnded={next}
|
||
onProgress={setProgress}
|
||
/>
|
||
);
|
||
default:
|
||
return <FallbackParticleView particle={particle} />;
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className="relative flex h-screen flex-col overflow-hidden bg-black text-white [--stream-safe-top:4rem] [--stream-safe-bottom:5rem]"
|
||
onMouseMove={handleMouseActivity}
|
||
onMouseLeave={() => setShowControls(false)}
|
||
>
|
||
{/* Top gradient safe zone */}
|
||
<div className="pointer-events-none absolute inset-x-0 top-0 z-[5] h-32 bg-gradient-to-b from-black/60 to-transparent" />
|
||
|
||
{/* TopBar — always visible */}
|
||
<div className="z-10 absolute left-0 right-0 mt-3">
|
||
<TopBar networkId={networkId} particle={currentParticle} streamParticle={streamParticle} />
|
||
</div>
|
||
|
||
{/* Main playback area */}
|
||
<div className="flex-1 overflow-hidden">
|
||
{currentParticle && (
|
||
<div
|
||
className="relative flex h-full w-full cursor-pointer items-center justify-center"
|
||
onClick={handlePlaybackClick}
|
||
>
|
||
{renderParticle(currentParticle)}
|
||
|
||
{fastPlayback && (
|
||
<div className="pointer-events-none absolute right-4 top-14 z-20 rounded-full bg-black/50 px-2.5 py-1 text-xs font-medium text-white backdrop-blur-sm">
|
||
1.5x
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Reaction bar — always visible */}
|
||
{currentParticle && (
|
||
<div className="absolute right-4 top-1/2 -translate-y-1/2 z-10">
|
||
<ReactionBar
|
||
reactions={getReactions(currentParticle)}
|
||
currentHumanId={authedUser?.id ?? ""}
|
||
humans={network?.humans}
|
||
onToggle={handleToggleReaction}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Composing indicator — left edge, always visible */}
|
||
<ComposingIndicator users={composingUsers} networkHumans={network?.humans} />
|
||
|
||
<ComposeOverlay
|
||
networkId={networkId}
|
||
targetPath={path}
|
||
onActiveChange={setComposeActive}
|
||
onStepChange={setComposeStep}
|
||
disabled={streamParticle.status === "closed"}
|
||
/>
|
||
|
||
{/* Bottom gradient safe zone for keyboard hints */}
|
||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-[5] h-1/4 bg-gradient-to-t from-black/60 to-transparent" />
|
||
|
||
{/* BottomBar */}
|
||
<BottomBar
|
||
visible={controlsVisible}
|
||
total={children.length}
|
||
current={currentIndex}
|
||
progress={progress}
|
||
onGoTo={goTo}
|
||
presenceBySegment={presenceBySegment}
|
||
onlineHumanIds={onlineHumanIds}
|
||
exitRemainingMs={exitRemainingMs}
|
||
onOpenKeybindings={() => setShowKeybindings(true)}
|
||
/>
|
||
|
||
<KeybindingsOverlay
|
||
open={showKeybindings}
|
||
onClose={() => setShowKeybindings(false)}
|
||
groups={STREAM_VIEW_KEYBINDINGS}
|
||
title="Stream View"
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function BottomBar({
|
||
visible,
|
||
total,
|
||
current,
|
||
progress,
|
||
onGoTo,
|
||
presenceBySegment,
|
||
onlineHumanIds,
|
||
exitRemainingMs,
|
||
onOpenKeybindings,
|
||
}: {
|
||
visible: boolean;
|
||
total: number;
|
||
current: number;
|
||
progress: number;
|
||
onGoTo: (index: number) => void;
|
||
presenceBySegment: Map<number, import("@/hooks/use-presence-positions").HumanPresence[]>;
|
||
onlineHumanIds: Set<string>;
|
||
exitRemainingMs: number | null;
|
||
onOpenKeybindings: () => void;
|
||
}) {
|
||
return (
|
||
<div className={cn(
|
||
"absolute inset-x-0 bottom-0 z-10 transition-all duration-300",
|
||
visible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-2 pointer-events-none",
|
||
)}>
|
||
{/* Presence avatars — above the blurred background */}
|
||
<PlaybackPageIndicator
|
||
total={total}
|
||
current={current}
|
||
progress={progress}
|
||
onGoTo={onGoTo}
|
||
presenceBySegment={presenceBySegment}
|
||
onlineHumanIds={onlineHumanIds}
|
||
layer="avatars"
|
||
/>
|
||
{/* Blurred background container — tracks + controls */}
|
||
<div className="pb-3">
|
||
<PlaybackPageIndicator
|
||
total={total}
|
||
current={current}
|
||
progress={progress}
|
||
onGoTo={onGoTo}
|
||
layer="tracks"
|
||
/>
|
||
<div className="flex items-center justify-center px-3 pt-2 gap-2">
|
||
{exitRemainingMs !== null && (
|
||
<div className="flex justify-center">
|
||
<span className="rounded-full bg-black/30 px-2 text-xs text-white/70 backdrop-blur-sm">
|
||
Closing in {Math.ceil(exitRemainingMs / 1000)}s
|
||
</span>
|
||
</div>
|
||
)}
|
||
<StreamViewControls
|
||
showEscape
|
||
onOpenKeybindings={onOpenKeybindings}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StreamViewControls({
|
||
showEscape,
|
||
onOpenKeybindings,
|
||
}: {
|
||
showEscape?: boolean;
|
||
onOpenKeybindings: () => void;
|
||
}) {
|
||
return (
|
||
<div className="flex items-center gap-4 text-sm text-white/50">
|
||
{showEscape && (
|
||
<span>
|
||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||
Esc
|
||
</kbd>{" "}
|
||
back
|
||
</span>
|
||
)}
|
||
<VideoAudioToggle />
|
||
<span>
|
||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||
Hold `
|
||
</kbd>{" "}
|
||
to reply
|
||
</span>
|
||
<span>
|
||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||
T
|
||
</kbd>{" "}
|
||
text
|
||
</span>
|
||
<span>
|
||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||
H
|
||
</kbd>{" "}
|
||
huddle
|
||
</span>
|
||
<kbd
|
||
role="button"
|
||
onClick={onOpenKeybindings}
|
||
className="cursor-pointer rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs transition-colors hover:text-white/80"
|
||
title="Show all shortcuts"
|
||
>
|
||
?
|
||
</kbd>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TopBar({ networkId, particle, streamParticle }: { networkId: string; particle: Particle | null; streamParticle: Particle & { type: "stream" } }) {
|
||
const navigate = useNavigate();
|
||
const network = useNetwork(networkId);
|
||
|
||
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 (
|
||
<div className="drag-region flex flex-row px-4 gap-5 items-center">
|
||
<WindowControls />
|
||
|
||
<Breadcrumb className="no-drag rounded-full bg-black/30 backdrop-blur-sm px-3 py-1 mx-auto">
|
||
<BreadcrumbList>
|
||
{streamParticle && (
|
||
<>
|
||
<BreadcrumbItem className="text-xs">
|
||
<BreadcrumbPage>{getParticleDisplayName(streamParticle)}</BreadcrumbPage>
|
||
</BreadcrumbItem>
|
||
</>
|
||
)}
|
||
|
||
{particle && (
|
||
<>
|
||
<BreadcrumbSeparator />
|
||
<BreadcrumbItem className="text-xs">
|
||
<BreadcrumbPage><ParticleBreadcrumbContent particle={particle} networkId={networkId} /></BreadcrumbPage>
|
||
</BreadcrumbItem>
|
||
</>
|
||
)}
|
||
</BreadcrumbList>
|
||
</Breadcrumb>
|
||
|
||
{hasActiveHuddle && (
|
||
<button
|
||
onClick={handleJoinHuddle}
|
||
className="no-drag flex items-center gap-2 rounded-full bg-red-500/20 px-3 py-1 backdrop-blur-sm transition-colors hover:bg-red-500/30"
|
||
>
|
||
<span className="relative flex size-2">
|
||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-red-400 opacity-75" />
|
||
<span className="relative inline-flex size-2 rounded-full bg-red-500" />
|
||
</span>
|
||
<AvatarGroup>
|
||
{huddleParticipants.map((humanId) => {
|
||
const human = network?.humans?.find((h) => h.id === humanId);
|
||
const initials = human ? getInitials(human.email) : "?";
|
||
return (
|
||
<Tooltip key={humanId}>
|
||
<TooltipTrigger asChild>
|
||
<Avatar size="sm">
|
||
<AvatarFallback className="bg-red-500/30 text-[8px] text-red-200">
|
||
{initials}
|
||
</AvatarFallback>
|
||
</Avatar>
|
||
</TooltipTrigger>
|
||
<TooltipContent>{human?.email ?? humanId}</TooltipContent>
|
||
</Tooltip>
|
||
);
|
||
})}
|
||
</AvatarGroup>
|
||
<span className="text-xs font-medium text-red-200">Join</span>
|
||
</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>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<span className="flex items-center gap-1.5">
|
||
<Avatar size="sm">
|
||
<AvatarFallback>
|
||
{initials}
|
||
</AvatarFallback>
|
||
</Avatar>
|
||
{name}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<span className="flex items-center gap-1.5">
|
||
<Avatar size="sm" className={isOnline ? "ring-2 ring-green-500" : ""}>
|
||
<AvatarFallback>
|
||
{initials}
|
||
</AvatarFallback>
|
||
</Avatar>
|
||
{prefix} - <RelativeTimestamp date={particle.created_at} />
|
||
</span>
|
||
)
|
||
}
|
||
|