From ed664a58574afc0dcc9e7ff3aeb4d644de77f5ee Mon Sep 17 00:00:00 2001 From: Natalie Bridgers Date: Fri, 16 Oct 2026 18:34:25 -0500 Subject: [PATCH] Keep important stream notices visible when chat is closed --- js/web/src/components/player/player.tsx | 8 +- js/web/src/components/stream/chat-panel.tsx | 18 ++- .../stream/stream-notification-provider.tsx | 38 ++++--- .../stream/stream-notifications.test.tsx | 57 ++++++++++ .../stream/stream-notifications.tsx | 22 +++- .../components/stream/stream-page.test.tsx | 104 +++++++++++++++--- .../components/stream/video-section.test.tsx | 94 ++++++++++++++++ .../src/components/stream/video-section.tsx | 33 ++++-- js/web/src/lib/stream-notification.ts | 1 + js/web/src/routes/$user/index.tsx | 34 ++++-- 10 files changed, 343 insertions(+), 66 deletions(-) create mode 100644 js/web/src/components/stream/video-section.test.tsx diff --git a/js/web/src/components/player/player.tsx b/js/web/src/components/player/player.tsx index 29cf97f91..e5f6db777 100644 --- a/js/web/src/components/player/player.tsx +++ b/js/web/src/components/player/player.tsx @@ -55,10 +55,10 @@ export type PlayerProps = { /** Toggle the danmu overlay. */ onShowDanmuChange?: (show: boolean) => void; /** - * Danmu overlay node rendered inside the player container so it + * Overlay node rendered inside the player container so it * travels with the video into fullscreen. */ - danmuOverlay?: ReactNode; + overlay?: ReactNode; /** Content-warning labels associated with the current video or stream. */ contentWarnings?: string[]; /** Whether the current user is the stream owner. */ @@ -172,7 +172,7 @@ export function Player({ onPlaying, showDanmu = false, onShowDanmuChange, - danmuOverlay, + overlay, contentWarnings = [], isStreamer, }: PlayerProps) { @@ -415,7 +415,7 @@ export function Player({ )} - {active && danmuOverlay} + {active && overlay} {active && ( ({ @@ -199,10 +205,14 @@ export function ChatPanel({ return (
- + {notificationManager ? ( + + ) : ( + + )}
(null); + +type Position = "top" | "bottom" | "top-right"; + export function StreamNotificationProvider({ - manager, position = "top", + importantOnly = false, }: { - manager: StreamNotificationManager; - position?: "top" | "bottom"; + position?: Position; + importantOnly?: boolean; }) { + const manager = useContext(StreamNotificationContext); + if (!manager) throw new Error("Stream notifications require a manager"); const [notifications, setNotifications] = useState(manager.getAll()); useEffect(() => manager.subscribe(setNotifications), [manager]); return (
- {notifications.map((notification) => ( - - ))} + {notifications.map((notification) => + importantOnly && !notification.important ? null : ( + + ), + )}
); } @@ -35,11 +44,12 @@ function NotificationItem({ position, }: { notification: StreamNotification; - position: "top" | "bottom"; + position: Position; }) { return (
event.stopPropagation()} + className={`animate-in fade-in pointer-events-auto rounded-lg border border-(--color-border) shadow-lg duration-200 motion-reduce:animate-none ${position === "bottom" ? "slide-in-from-bottom-2" : "slide-in-from-top-2"} ${notification.render ? "" : "bg-(--color-bg-elevated) text-(--color-fg)"}`} > {notification.render ? ( notification.render() diff --git a/js/web/src/components/stream/stream-notifications.test.tsx b/js/web/src/components/stream/stream-notifications.test.tsx index 95af5b172..47882eab7 100644 --- a/js/web/src/components/stream/stream-notifications.test.tsx +++ b/js/web/src/components/stream/stream-notifications.test.tsx @@ -2,6 +2,7 @@ import { makeLivestreamStore } from "@streamplace/core"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { StreamNotificationProvider } from "./stream-notification-provider"; import { StreamNotifications } from "./stream-notifications"; const { mockUseSession } = vi.hoisted(() => ({ mockUseSession: vi.fn() })); @@ -96,6 +97,44 @@ describe("TeleportNotification", () => { expect(container.textContent).not.toContain("teleporting-to"); }); + it("shares important notices with the video while keeping pins in chat", async () => { + store.setState({ + pinnedComment: pin("pinned message"), + activeTeleport: teleport(new Date(Date.now() + 1_000).toISOString()), + }); + await act(async () => + root.render( + +
+ +
+
+ +
+
, + ), + ); + + expect(container.querySelector("#video")?.textContent).toContain( + "teleporting-to", + ); + expect(container.querySelector("#video")?.textContent).not.toContain( + "pinned message", + ); + expect(container.querySelector("#chat")?.textContent).toContain( + "pinned message", + ); + expect(container.querySelector("#chat")?.textContent).toContain( + "teleporting-to", + ); + + await act(async () => vi.advanceTimersByTimeAsync(1_000)); + expect(container.querySelector("#video")?.textContent).toBe(""); + expect(container.querySelector("#chat")?.textContent).toContain( + "pinned message", + ); + }); + it("does not clear a newer teleport when an older cancellation completes", async () => { let finishDelete!: () => void; const deletion = new Promise((resolve) => { @@ -128,6 +167,24 @@ describe("TeleportNotification", () => { expect(store.getState().activeTeleportCID).toBe("new-cid"); }); + it("does not send notification clicks to the player behind it", async () => { + let playerClicks = 0; + store.setState({ + activeTeleport: teleport(new Date(Date.now() + 10_000).toISOString()), + }); + await act(async () => + root.render( +
playerClicks++}> + +
, + ), + ); + const notice = container.querySelector("p"); + expect(notice).not.toBeNull(); + await act(async () => notice?.click()); + expect(playerClicks).toBe(0); + }); + it("keeps teleports visible when pins are hidden", async () => { store.setState({ pinnedComment: pin("hidden pin"), diff --git a/js/web/src/components/stream/stream-notifications.tsx b/js/web/src/components/stream/stream-notifications.tsx index 447994e6f..da182eb24 100644 --- a/js/web/src/components/stream/stream-notifications.tsx +++ b/js/web/src/components/stream/stream-notifications.tsx @@ -8,7 +8,13 @@ import { type FacetFeature, } from "@streamplace/core"; import { EyeOff, Pin, X } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { + useCallback, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; import { useTranslation } from "react-i18next"; import type { ChatMessageViewHydrated, @@ -21,7 +27,10 @@ import { useCanModerate } from "../../hooks/use-can-moderate"; import { useModerationActions } from "../../hooks/use-moderation-actions"; import { useSession } from "../../lib/session"; import { StreamNotificationManager } from "../../lib/stream-notification"; -import { StreamNotificationProvider } from "./stream-notification-provider"; +import { + StreamNotificationContext, + StreamNotificationProvider, +} from "./stream-notification-provider"; function rgbColor( color?: { red: number; green: number; blue: number } | null, @@ -33,9 +42,11 @@ function rgbColor( export function StreamNotifications({ store, showPinnedComment = true, + children, }: { store: LivestreamStore; showPinnedComment?: boolean; + children?: ReactNode; }) { const state = useStore( store, @@ -71,6 +82,7 @@ export function StreamNotifications({ if (teleport) { manager.show({ id: "teleport", + important: true, duration: 0, render: () => ( ; + return ( + + {children ?? } + + ); } function PinnedNotification({ diff --git a/js/web/src/components/stream/stream-page.test.tsx b/js/web/src/components/stream/stream-page.test.tsx index 13495549c..e35b71013 100644 --- a/js/web/src/components/stream/stream-page.test.tsx +++ b/js/web/src/components/stream/stream-page.test.tsx @@ -3,12 +3,15 @@ import { act, type ComponentType, type ReactNode } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Route } from "../../routes/$user/index"; +import { StreamNotificationProvider } from "./stream-notification-provider"; const fixture = vi.hoisted(() => ({ store: null as unknown as LivestreamStore, liveness: "live", navigate: vi.fn(), profiles: {}, + isWide: true, + fullscreen: false, })); vi.mock("@tanstack/react-router", () => ({ @@ -33,16 +36,25 @@ vi.mock("@/hooks/use-stream-avatar", () => ({ })); vi.mock("@/hooks/use-avatars", () => ({ default: () => fixture.profiles })); vi.mock("@/contexts/fullscreen-context", () => ({ - useFullscreen: () => ({ theatre: false }), + useFullscreen: () => ({ theatre: false, fullscreen: fixture.fullscreen }), })); vi.mock("@/lib/store/hooks", () => ({ useStreamplaceUrl: () => "https://node.example", })); vi.mock("@/components/stream/video-section", () => ({ - VideoSection: () => null, + VideoSection: ({ overlay }: { overlay?: ReactNode }) => ( +
+
+ ), })); vi.mock("@/components/stream/chat-sidebar", () => ({ - ChatSidebar: () => null, + ChatSidebar: () => ( + + ), })); vi.mock("@/components/stream/stream-info", () => ({ StreamInfo: () => null, @@ -52,6 +64,9 @@ vi.mock("@/components/stream/stream-info", () => ({ vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key }), })); +vi.mock("@/lib/session", () => ({ + useSession: () => ({ did: null, pdsAgent: null }), +})); describe("stream chat during a teleport", () => { let container: HTMLDivElement; @@ -74,10 +89,12 @@ describe("stream chat during a teleport", () => { beforeEach(() => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.stubGlobal("matchMedia", () => ({ matches: true })); + vi.stubGlobal("matchMedia", () => ({ matches: fixture.isWide })); localStorage.setItem("streamplace:chat-open", "false"); fixture.store = makeLivestreamStore(); fixture.liveness = "live"; + fixture.isWide = true; + fixture.fullscreen = false; container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); @@ -90,28 +107,81 @@ describe("stream chat during a teleport", () => { vi.unstubAllGlobals(); }); - it("reveals a teleport in closed chat without overwriting the saved preference", async () => { - await act(async () => root.render()); - expect(container.querySelector('[aria-label="chat-open"]')).not.toBeNull(); + it.each([true, false])( + "shows important notices over the video while chat stays closed (wide: %s)", + async (isWide) => { + fixture.isWide = isWide; + await act(async () => root.render()); + const close = container.querySelector( + '[aria-label="chat-close"]', + ); + if (close) await act(async () => close.click()); + expect( + container.querySelector('[aria-label="chat-open"]'), + ).not.toBeNull(); + + await act(async () => startTeleport()); + + expect( + container.querySelector('[aria-label="chat-open"]'), + ).not.toBeNull(); + expect( + container.querySelector("video")?.parentElement?.textContent, + ).toContain("teleporting-to"); + expect(localStorage.getItem("streamplace:chat-open")).toBe("false"); + }, + ); + it("moves important notices between video and chat as the viewer toggles chat", async () => { + await act(async () => root.render()); await act(async () => startTeleport()); + await act(async () => + container + .querySelector('[aria-label="chat-open"]') + ?.click(), + ); + + expect( + container.querySelector("video")?.parentElement?.textContent, + ).not.toContain("teleporting-to"); + expect(container.querySelector("aside")?.textContent).toContain( + "teleporting-to", + ); + + await act(async () => + container + .querySelector('[aria-label="chat-close"]') + ?.click(), + ); - expect(container.querySelector('[aria-label="chat-close"]')).not.toBeNull(); - expect(localStorage.getItem("streamplace:chat-open")).toBe("false"); + expect( + container.querySelector("video")?.parentElement?.textContent, + ).toContain("teleporting-to"); }); - it("keeps the countdown accessible when liveness changes, and allows intentional closing", async () => { + it("keeps the video countdown accessible when liveness changes", async () => { await act(async () => root.render()); await act(async () => startTeleport()); fixture.liveness = "offline"; await act(async () => root.render()); - const close = container.querySelector( - '[aria-label="chat-close"]', - ); - expect(close).not.toBeNull(); - - await act(async () => close?.click()); - expect(container.querySelector('[aria-label="chat-open"]')).not.toBeNull(); + expect( + container.querySelector("video")?.parentElement?.textContent, + ).toContain("teleporting-to"); + }); + + it("shows important notices over fullscreen video even when chat was open", async () => { + await act(async () => root.render()); + await act(async () => + container + .querySelector('[aria-label="chat-open"]') + ?.click(), + ); + fixture.fullscreen = true; + await act(async () => root.render()); + await act(async () => startTeleport()); + expect( + container.querySelector("video")?.parentElement?.textContent, + ).toContain("teleporting-to"); }); }); diff --git a/js/web/src/components/stream/video-section.test.tsx b/js/web/src/components/stream/video-section.test.tsx new file mode 100644 index 000000000..bef34aae0 --- /dev/null +++ b/js/web/src/components/stream/video-section.test.tsx @@ -0,0 +1,94 @@ +import { act, type RefObject } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { VideoSectionInner } from "./video-section"; + +const fixture = vi.hoisted(() => ({ + playerRoot: null as HTMLDivElement | null, +})); +vi.mock("../../contexts/fullscreen-context", () => ({ + useFullscreen: () => ({ theatre: false, fullscreen: false }), +})); +vi.mock("../player/hls-player", () => ({ HLSPlayer: () => null })); +vi.mock("../player/webrtc-player", () => ({ WebRTCPlayer: () => null })); +vi.mock("../player/content-warning-overlay", () => ({ + ContentWarningOverlay: () => null, +})); +vi.mock("./user-offline", () => ({ UserOffline: () => null })); +vi.mock("../player/player-controls", () => ({ + PlayerControls: ({ + containerRef, + }: { + containerRef: RefObject; + }) => ( + + ), +})); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +describe("video notification overlays", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + fixture.playerRoot = null; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + }); + + async function render(liveness: "live" | "offline" | "never-live") { + await act(async () => + root.render( + important notice} + />, + ), + ); + await act(async () => + container.querySelector("button")?.click(), + ); + } + + it("keeps the notification inside the player's fullscreen container", async () => { + await render("live"); + expect(fixture.playerRoot).not.toBeNull(); + expect(fixture.playerRoot?.querySelector("aside")?.textContent).toBe( + "important notice", + ); + }); + + it.each(["offline", "never-live"] as const)( + "shows the notification outside the hidden player when %s", + async (liveness) => { + await render(liveness); + const outsidePlayer = Array.from( + container.querySelectorAll("aside"), + ).filter((notice) => !fixture.playerRoot?.contains(notice)); + expect(outsidePlayer).toHaveLength(1); + expect(outsidePlayer[0].textContent).toBe("important notice"); + }, + ); +}); diff --git a/js/web/src/components/stream/video-section.tsx b/js/web/src/components/stream/video-section.tsx index 0555e50b7..cf11dc63f 100644 --- a/js/web/src/components/stream/video-section.tsx +++ b/js/web/src/components/stream/video-section.tsx @@ -1,5 +1,5 @@ import type { LivestreamStore } from "@streamplace/core"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { useStore } from "zustand"; import { useShallow } from "zustand/react/shallow"; @@ -38,10 +38,12 @@ export function VideoSection({ store, user, liveness, + overlay, }: { store: LivestreamStore; user: string; liveness: Liveness; + overlay?: ReactNode; }) { const [showDanmu, setShowDanmu] = useState(readDanmuPreference); @@ -95,6 +97,7 @@ export function VideoSection({ danmuLaneCount={danmuLaneCount} danmuMaxMessages={danmuMaxMessages} isStreamer={isStreamer} + overlay={overlay} /> ); } @@ -121,6 +124,7 @@ export function VideoSectionInner({ danmuLaneCount = 12, danmuMaxMessages = 50, isStreamer = false, + overlay, }: { user: string; liveness: Liveness; @@ -140,6 +144,7 @@ export function VideoSectionInner({ danmuLaneCount?: number; danmuMaxMessages?: number; isStreamer?: boolean; + overlay?: ReactNode; }) { const { t } = useTranslation("common"); const { theatre } = useFullscreen(); @@ -195,17 +200,20 @@ export function VideoSectionInner({ showDanmu={showDanmu} onShowDanmuChange={onShowDanmuChange} onError={(message) => captureError(message, { user, mode })} - danmuOverlay={ - store ? ( - - ) : undefined + overlay={ + <> + {store && ( + + )} + {overlay} + } />
@@ -230,6 +238,7 @@ export function VideoSectionInner({ )} {neverLive && } + {!showPlayer && overlay} {showPlayer && isStreamer && diff --git a/js/web/src/lib/stream-notification.ts b/js/web/src/lib/stream-notification.ts index 7e12e37ba..463b7464d 100644 --- a/js/web/src/lib/stream-notification.ts +++ b/js/web/src/lib/stream-notification.ts @@ -5,6 +5,7 @@ export type StreamNotificationConfig = { message?: string; render?: () => ReactNode; duration?: number; + important?: boolean; }; export type StreamNotification = StreamNotificationConfig; diff --git a/js/web/src/routes/$user/index.tsx b/js/web/src/routes/$user/index.tsx index 80578a138..d2de0591f 100644 --- a/js/web/src/routes/$user/index.tsx +++ b/js/web/src/routes/$user/index.tsx @@ -4,6 +4,8 @@ import { CopyButton, StreamInfo, } from "@/components/stream/stream-info"; +import { StreamNotificationProvider } from "@/components/stream/stream-notification-provider"; +import { StreamNotifications } from "@/components/stream/stream-notifications"; import { VideoSection } from "@/components/stream/video-section"; import { Button, buttonVariants } from "@/components/ui/button"; import { @@ -107,7 +109,11 @@ function StreamPage() { ); } - return ; + return ( + + + + ); } function CreatorState({ @@ -151,7 +157,7 @@ function StreamBody({ store, user }: { store: LivestreamStore; user: string }) { [activeTeleport?.streamer], ); const teleportProfiles = useAvatars(teleportDids); - const { theatre } = useFullscreen(); + const { theatre, fullscreen } = useFullscreen(); useEffect(() => { if (!activeTeleport || activeTeleport.streamer === livestreamDid) return; @@ -193,10 +199,6 @@ function StreamBody({ store, user }: { store: LivestreamStore; user: string }) { const wasOffline = useRef(isOffline); const userChangedChat = useRef(false); - useEffect(() => { - if (activeTeleport) setChatOpen(true); - }, [activeTeleport]); - const toggleChat = useCallback(() => { userChangedChat.current = true; setChatOpen((prev) => { @@ -220,10 +222,6 @@ function StreamBody({ store, user }: { store: LivestreamStore; user: string }) { // during this visit. This covers false-offline startup results in any // orientation while preserving desktop preferences. useEffect(() => { - if (activeTeleport) { - wasOffline.current = isOffline; - return; - } const nextChatOpen = chatOpenAfterLivenessChange({ isOffline, wasOffline: wasOffline.current, @@ -235,7 +233,7 @@ function StreamBody({ store, user }: { store: LivestreamStore; user: string }) { setChatOpen(nextChatOpen); } wasOffline.current = isOffline; - }, [isOffline, chatOpen, activeTeleport]); + }, [isOffline, chatOpen]); return (
@@ -243,7 +241,19 @@ function StreamBody({ store, user }: { store: LivestreamStore; user: string }) {
- + + ) + } + /> {!theatre && (
-- 2.54.0