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);