diff --git a/js/core/src/slash-commands/teleport.ts b/js/core/src/slash-commands/teleport.ts index ebd6f2a1f..0349686f4 100644 --- a/js/core/src/slash-commands/teleport.ts +++ b/js/core/src/slash-commands/teleport.ts @@ -26,7 +26,7 @@ export async function createTeleport( targetHandle: string, countdownSeconds: number, livestream?: { uri: string; cid: string } | null, - setActiveTeleportUri?: (uri: string | null) => void, + onTeleportCreated?: (uri: string, targetDID: string) => void, ): Promise<{ success: boolean; error?: string }> { if (countdownSeconds < 5 || countdownSeconds > 300) { return { @@ -76,9 +76,7 @@ export async function createTeleport( { repo: userDID as any }, ); - if (setActiveTeleportUri) { - setActiveTeleportUri(result.uri); - } + onTeleportCreated?.(result.uri, targetDID); return { success: true }; } catch (err) { @@ -97,7 +95,7 @@ export function registerTeleportCommand( cid: string; streamerDid: string; } | null, - setActiveTeleportUri?: (uri: string | null) => void, + onTeleportCreated?: (uri: string, targetDID: string) => void, onOpenModal?: () => void, ): () => void { const teleportHandler: SlashCommandHandler = async ( @@ -198,10 +196,7 @@ export function registerTeleportCommand( { repo: userDID as any }, ); - // store the URI in the livestream store - if (setActiveTeleportUri) { - setActiveTeleportUri(result.uri); - } + onTeleportCreated?.(result.uri, targetDID); return { handled: true }; } catch (err) { diff --git a/js/i18n/locales/en-US/common.ftl b/js/i18n/locales/en-US/common.ftl index 918fa366a..1e94f5171 100644 --- a/js/i18n/locales/en-US/common.ftl +++ b/js/i18n/locales/en-US/common.ftl @@ -430,7 +430,7 @@ player-latency = Latency player-quality-auto = Auto ## Video Player - Stats panel -player-stats = Player stats +player-stats = Stats for Nerds player-stats-resolution = Resolution player-stats-viewport = Viewport player-stats-bitrate = Bitrate diff --git a/js/web/src/components/dashboard/chat-panel.tsx b/js/web/src/components/dashboard/chat-panel.tsx index 0d82505d9..5dbdb2fa1 100644 --- a/js/web/src/components/dashboard/chat-panel.tsx +++ b/js/web/src/components/dashboard/chat-panel.tsx @@ -1,4 +1,6 @@ +import { useToast } from "@/hooks/use-toast"; import type { LivestreamStore } from "@streamplace/core"; +import { useNavigate } from "@tanstack/react-router"; import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { useStore } from "zustand"; @@ -12,6 +14,8 @@ import { ChatPanel as ChatMessages } from "../stream/chat-panel"; */ export function ChatPanelWidget({ store }: { store: LivestreamStore }) { const { t } = useTranslation("common"); + const toast = useToast(); + const navigate = useNavigate(); const state = useStore( store, useShallow((s) => ({ @@ -46,7 +50,21 @@ export function ChatPanelWidget({ store }: { store: LivestreamStore }) { {/* Input */}
- + { + toast.show("Teleport started", "Visit the receiving livestream.", { + variant: "success", + actionLabel: "Go to livestream", + onAction: () => { + void navigate({ + to: "/$user", + params: { user: targetDID }, + }); + }, + }); + }} + />
); diff --git a/js/web/src/components/stream/chat-input.tsx b/js/web/src/components/stream/chat-input.tsx index 865df37bb..0f5d990a3 100644 --- a/js/web/src/components/stream/chat-input.tsx +++ b/js/web/src/components/stream/chat-input.tsx @@ -299,7 +299,13 @@ function createEmojiSuggestion({ getSkinTone }: EmojiSuggestionProps) { }; } -export function ChatInput({ store }: { store: LivestreamStore }) { +export function ChatInput({ + store, + onTeleportCreated, +}: { + store: LivestreamStore; + onTeleportCreated?: (uri: string, targetDID: string) => void; +}) { const { t } = useTranslation("common"); const { state, pdsAgent, did } = useSession(); const isAuthed = state.status === "authenticated"; @@ -327,10 +333,13 @@ export function ChatInput({ store }: { store: LivestreamStore }) { streamerDid: livestream.author.did, } : null, - (uri) => store.setState({ activeTeleportUri: uri }), + (uri, targetDID) => { + store.setState({ activeTeleportUri: uri }); + onTeleportCreated?.(uri, targetDID); + }, () => setTeleportOpen(true), ); - }, [did, livestream, pdsAgent, store]); + }, [did, livestream, pdsAgent, store, onTeleportCreated]); // Keep module-level refs fresh so the suggestion items functions always // have up-to-date data without recreating the editor. diff --git a/js/web/src/components/stream/stream-info.tsx b/js/web/src/components/stream/stream-info.tsx index 0dcbac4b8..17ceff54d 100644 --- a/js/web/src/components/stream/stream-info.tsx +++ b/js/web/src/components/stream/stream-info.tsx @@ -1,5 +1,4 @@ import { useToast } from "@/hooks/use-toast"; -import { useStore } from "@/lib/store"; import { useStreamplaceUrl } from "@/lib/store/hooks"; import { cn } from "@/lib/utils"; import type { LivestreamStore } from "@streamplace/core"; @@ -17,6 +16,7 @@ import { place } from "streamplace"; import { useStore as useLivestreamStore } from "zustand"; import { useShallow } from "zustand/react/shallow"; import { useCanModerate } from "../../hooks/use-can-moderate"; +import { useFollow } from "../../hooks/use-follow"; import type { Liveness } from "../../hooks/use-liveness-state"; import { useModerationActions } from "../../hooks/use-moderation-actions"; import { useSession } from "../../lib/session"; @@ -93,8 +93,8 @@ export function StreamInfo({ ); const { state: sessionState } = useSession(); - const followUser = useStore((s) => s.followUser); - const [following, setFollowing] = useState(false); + const follow = useFollow(state.livestream?.author.did); + const toast = useToast(); const record = state.livestream?.record; const author = state.livestream?.author; const title = record?.title || user; @@ -107,14 +107,14 @@ export function StreamInfo({ const node = useStreamplaceUrl(); const handleFollow = async () => { - if (!author?.did) return; - setFollowing(true); try { - await followUser(author.did); - } catch (e) { - console.error("Failed to follow:", e); - } finally { - setFollowing(false); + await follow.toggle(); + } catch (error) { + toast.show( + "Failed to update follow", + error instanceof Error ? error.message : "Please try again.", + { variant: "error" }, + ); } }; @@ -181,14 +181,32 @@ export function StreamInfo({
{sessionState.status === "authenticated" && - sessionState.session.did !== author?.did && ( + author?.did && + sessionState.session.did !== author.did && ( )} diff --git a/js/web/src/hooks/use-follow.test.tsx b/js/web/src/hooks/use-follow.test.tsx new file mode 100644 index 000000000..48f34d3b6 --- /dev/null +++ b/js/web/src/hooks/use-follow.test.tsx @@ -0,0 +1,243 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useFollow } from "./use-follow"; + +const { mockUseSession } = vi.hoisted(() => ({ mockUseSession: vi.fn() })); +vi.mock("@/lib/session", () => ({ useSession: mockUseSession })); +vi.mock("@/lib/store", () => ({ + useStore: (selector: (state: { url: string }) => unknown) => + selector({ url: "https://node.example" }), +})); + +const VIEWER = "did:plc:viewer"; +const STREAMER = "did:plc:streamer"; +const FOLLOW_URI = `at://${VIEWER}/app.bsky.graph.follow/record`; + +function response(uri: string | null) { + return new Response(JSON.stringify(uri ? { follow: { uri } } : {})); +} + +describe("useFollow", () => { + let container: HTMLDivElement; + let root: Root; + let client: QueryClient; + let current: ReturnType; + let fetchMock: ReturnType; + let agent: { + follow: ReturnType; + deleteFollow: ReturnType; + }; + + function Probe({ subject }: { subject?: string }) { + current = useFollow(subject); + return {current.following ? "Following" : "Follow"}; + } + + async function render(subject: string | undefined = STREAMER) { + await act(async () => { + root.render( + + + , + ); + }); + } + + async function waitForLoaded() { + await act(async () => { + await vi.runAllTimersAsync(); + }); + expect(current.loading).toBe(false); + } + + beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + fetchMock = vi.fn().mockResolvedValue(response(null)); + vi.stubGlobal("fetch", fetchMock); + agent = { + follow: vi.fn().mockResolvedValue({ uri: FOLLOW_URI }), + deleteFollow: vi.fn().mockResolvedValue(undefined), + }; + mockUseSession.mockReturnValue({ did: VIEWER, pdsAgent: agent }); + client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity } }, + }); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + client.clear(); + container.remove(); + mockUseSession.mockReset(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it("loads an existing follow record for the viewer and streamer", async () => { + fetchMock.mockResolvedValue(response(FOLLOW_URI)); + await render(); + await waitForLoaded(); + + expect(container.textContent).toBe("Following"); + const url = new URL(fetchMock.mock.calls[0][0]); + expect(url.origin).toBe("https://node.example"); + expect(url.searchParams.get("userDID")).toBe(VIEWER); + expect(url.searchParams.get("subjectDID")).toBe(STREAMER); + await act(async () => current.toggle()); + expect(agent.deleteFollow).toHaveBeenCalledWith(FOLLOW_URI); + expect(container.textContent).toBe("Follow"); + }); + + it("keeps the returned URI after following so the next click unfollows", async () => { + await render(); + await waitForLoaded(); + expect(container.textContent).toBe("Follow"); + + await act(async () => current.toggle()); + expect(agent.follow).toHaveBeenCalledWith(STREAMER); + expect(container.textContent).toBe("Following"); + await act(async () => current.toggle()); + expect(agent.deleteFollow).toHaveBeenCalledWith(FOLLOW_URI); + expect(container.textContent).toBe("Follow"); + }); + + it("does not allow a follow before the lookup completes", async () => { + fetchMock.mockReturnValue(new Promise(() => {})); + await render(); + expect(current.loading).toBe(true); + await act(async () => current.toggle()); + expect(agent.follow).not.toHaveBeenCalled(); + }); + + it("keeps lookup errors distinct from not following", async () => { + fetchMock.mockResolvedValue(new Response("failed", { status: 500 })); + await render(); + await waitForLoaded(); + expect(current.error).toBeInstanceOf(Error); + await act(async () => current.toggle()); + expect(agent.follow).not.toHaveBeenCalled(); + }); + + it("preserves the record when unfollow fails", async () => { + fetchMock.mockResolvedValue(response(FOLLOW_URI)); + const error = new Error("write failed"); + agent.deleteFollow.mockRejectedValue(error); + await render(); + await waitForLoaded(); + await act(async () => { + await expect(current.toggle()).rejects.toBe(error); + }); + expect(container.textContent).toBe("Following"); + expect(current.loading).toBe(false); + }); + + it("preserves not-following state when follow fails", async () => { + const error = new Error("write failed"); + agent.follow.mockRejectedValue(error); + await render(); + await waitForLoaded(); + await act(async () => { + await expect(current.toggle()).rejects.toBe(error); + }); + expect(container.textContent).toBe("Follow"); + expect(current.loading).toBe(false); + }); + + it("blocks another toggle while a write is pending", async () => { + let resolve!: (value: { uri: string }) => void; + agent.follow.mockImplementation( + () => + new Promise<{ uri: string }>((done) => { + resolve = done; + }), + ); + await render(); + await waitForLoaded(); + let write!: Promise; + await act(async () => { + write = current.toggle(); + }); + expect(current.loading).toBe(true); + await act(async () => current.toggle()); + expect(agent.follow).toHaveBeenCalledTimes(1); + await act(async () => { + resolve({ uri: FOLLOW_URI }); + await write; + }); + expect(container.textContent).toBe("Following"); + }); + + it("does not apply a late follow write to a different streamer", async () => { + fetchMock.mockImplementation(async () => response(null)); + let resolve!: (value: { uri: string }) => void; + agent.follow.mockImplementation( + () => + new Promise<{ uri: string }>((done) => { + resolve = done; + }), + ); + await render(); + await waitForLoaded(); + let write!: Promise; + await act(async () => { + write = current.toggle(); + }); + await render("did:plc:other"); + await act(async () => { + resolve({ uri: FOLLOW_URI }); + await write; + await vi.runAllTimersAsync(); + }); + expect(container.textContent).toBe("Follow"); + expect( + client.getQueryData(["follow", "https://node.example", VIEWER, STREAMER]), + ).toBe(FOLLOW_URI); + }); + + it("does not apply a late lookup to a different streamer", async () => { + let resolve!: (value: Response) => void; + fetchMock.mockImplementationOnce( + () => + new Promise((done) => { + resolve = done; + }), + ); + await render(); + await render("did:plc:other"); + await waitForLoaded(); + await act(async () => resolve(response(FOLLOW_URI))); + expect(container.textContent).toBe("Follow"); + }); + + it("does not reuse another viewer's follow state", async () => { + fetchMock.mockResolvedValueOnce(response(FOLLOW_URI)); + await render(); + await waitForLoaded(); + mockUseSession.mockReturnValue({ + did: "did:plc:other-viewer", + pdsAgent: agent, + }); + await render(); + await waitForLoaded(); + expect(container.textContent).toBe("Follow"); + const url = new URL(fetchMock.mock.calls.at(-1)![0]); + expect(url.searchParams.get("userDID")).toBe("did:plc:other-viewer"); + }); + + it("skips lookups and writes when logged out or viewing yourself", async () => { + mockUseSession.mockReturnValue({ did: null, pdsAgent: null }); + await render(); + await act(async () => current.toggle()); + mockUseSession.mockReturnValue({ did: VIEWER, pdsAgent: agent }); + await render(VIEWER); + await act(async () => current.toggle()); + expect(fetchMock).not.toHaveBeenCalled(); + expect(agent.follow).not.toHaveBeenCalled(); + }); +}); diff --git a/js/web/src/hooks/use-follow.ts b/js/web/src/hooks/use-follow.ts new file mode 100644 index 000000000..b0e63bec5 --- /dev/null +++ b/js/web/src/hooks/use-follow.ts @@ -0,0 +1,66 @@ +import { useSession } from "@/lib/session"; +import { useStore } from "@/lib/store"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; + +export function useFollow(subjectDID: string | undefined) { + const { did, pdsAgent } = useSession(); + const node = useStore((state) => state.url); + const queryClient = useQueryClient(); + const [pending, setPending] = useState(false); + const enabled = !!pdsAgent && !!did && !!subjectDID && did !== subjectDID; + const queryKey = ["follow", node, did, subjectDID]; + const query = useQuery({ + queryKey, + enabled, + queryFn: async ({ signal }): Promise => { + const params = new URLSearchParams({ + userDID: did!, + subjectDID: subjectDID!, + }); + const response = await fetch( + `${node}/xrpc/place.stream.graph.getFollowingUser?${params}`, + { credentials: "include", signal }, + ); + if (!response.ok) throw new Error("Failed to load follow status"); + const data: { follow?: { uri: string } } = await response.json(); + return data.follow?.uri ?? null; + }, + }); + + const toggle = async () => { + if ( + !enabled || + !pdsAgent || + !subjectDID || + query.data === undefined || + query.isFetching || + query.isError || + pending + ) { + return; + } + + setPending(true); + try { + await queryClient.cancelQueries({ queryKey }); + if (query.data) { + await pdsAgent.deleteFollow(query.data); + queryClient.setQueryData(queryKey, null); + } else { + const result = await pdsAgent.follow(subjectDID); + // Use the write result rather than waiting for the node to index it. + queryClient.setQueryData(queryKey, result.uri); + } + } finally { + setPending(false); + } + }; + + return { + following: !!query.data, + loading: enabled && (query.isPending || query.isFetching || pending), + error: query.error, + toggle, + }; +} diff --git a/js/web/src/lib/store/slices/blueskySlice.ts b/js/web/src/lib/store/slices/blueskySlice.ts index 09e027053..980ec7aad 100644 --- a/js/web/src/lib/store/slices/blueskySlice.ts +++ b/js/web/src/lib/store/slices/blueskySlice.ts @@ -88,8 +88,6 @@ export interface BlueskySlice { blue: number, selfLabels?: string[], ) => Promise; - followUser: (subjectDID: string) => Promise; - unfollowUser: (subjectDID: string, followUri?: string) => Promise; getServerSettingsFromPDS: () => Promise; createServerSettingsRecord: ( patch: Partial>, @@ -805,48 +803,6 @@ export const createBlueskySlice: StateCreator< } }, - followUser: async (subjectDID: string) => { - const state = get() as BlueskySlice; - if (!state.pdsAgent) { - throw new Error("No agent"); - } - const did = state.oauthSession?.did; - if (!did) { - throw new Error("No DID"); - } - await state.pdsAgent.follow(subjectDID); - }, - - unfollowUser: async (subjectDID: string, followUri?: string) => { - const state = get() as BlueskySlice; - if (!state.pdsAgent) { - throw new Error("No agent"); - } - const did = state.oauthSession?.did; - if (!did) { - throw new Error("No DID"); - } - - if (followUri) { - await state.pdsAgent.deleteFollow(followUri); - } else { - const streamplaceUrl = get().url; - const res = await fetch( - `${streamplaceUrl}/xrpc/place.stream.graph.getFollowingUser?subjectDID=${encodeURIComponent(subjectDID)}&userDID=${encodeURIComponent(did)}`, - { - credentials: "include", - }, - ); - const data = await res.json(); - - if (!data.follow || !data.follow.uri) { - throw new Error("Follow record not found"); - } - - await state.pdsAgent.deleteFollow(data.follow.uri); - } - }, - getServerSettingsFromPDS: async () => { const state = get() as BlueskySlice; const did = state.oauthSession?.did; diff --git a/js/web/src/styles.css b/js/web/src/styles.css index 2d21ff0dc..fda9cbee4 100644 --- a/js/web/src/styles.css +++ b/js/web/src/styles.css @@ -56,7 +56,7 @@ code { .dark { --background: oklch(0.18 0.03 308.5); --foreground: oklch(0.9498 0.0183 336.6); - --card: oklch(0.2432 0.03 326.7); + --card: oklch(0.2432 0.0375 326.7); --card-foreground: oklch(0.9498 0.0183 336.6); --popover: oklch(0.2123 0.0376 318); --popover-foreground: oklch(0.9498 0.0183 336.6); @@ -78,7 +78,7 @@ code { --chart-3: oklch(0.46 0.08 250); --chart-4: oklch(0.48 0.035 65); --chart-5: oklch(0.44 0.045 155); - --sidebar: oklch(0.26 0.02 326.7); + --sidebar: oklch(0.2432 0.0375 326.7); --sidebar-foreground: oklch(0.9498 0.0183 336.6); --sidebar-primary: oklch(0.6803 0.2158 339.7); --sidebar-primary-foreground: oklch(0.1841 0.0279 308.5);