feat(player): the YouTube player and the file viewer are named in the address
The player names what it plays in its own history entry (watch, wrel, wtab): a reload or a
bookmark reopens it on the page that hosts it, with that page's list as the queue, or alone
from the catalog (GET /api/videos/{id}/row); a page that hosts none gets the shell's. Back
lands on the page's address without it (useBackToClose's url; ui/Modal passes it on). The
Files viewer and player do the same (fview, fplay). The Plex and channel-manager views are
always written in full, as their fallback is remembered state.
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import { lazy, Suspense, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { type Video, type VideoStatus } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { useStableCallback } from "../../lib/useLatest";
|
||||
import { writeVideoState } from "../video/videoWrites";
|
||||
import { useOpenLinkedPlayer } from "./useOpenLinkedPlayer";
|
||||
|
||||
const PlayerModal = lazy(() => import("./PlayerModal"));
|
||||
|
||||
/** The shell's player, for an address that names one over a page that hosts none of its own (the
|
||||
* channel manager, where the watched-videos dialog played it; any page a link names): the video
|
||||
* alone. Decided at boot, once — `hosted` is whether the page on screen then opens it itself. */
|
||||
export function LinkedPlayer({ hosted }: { hosted: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const [own] = useState(!hosted);
|
||||
const [video, setVideo] = useState<Video | null>(null);
|
||||
useOpenLinkedPlayer(
|
||||
own,
|
||||
() => undefined,
|
||||
(v) => setVideo(v),
|
||||
);
|
||||
const onState = useStableCallback((id: string, status: VideoStatus) => {
|
||||
const write = writeVideoState(qc, id, status);
|
||||
write
|
||||
.then(() => qc.invalidateQueries({ queryKey: qk.feed() }))
|
||||
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.videoState")));
|
||||
return write;
|
||||
});
|
||||
const close = useStableCallback(() => setVideo(null));
|
||||
if (!video) return null;
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<PlayerModal video={video} startAt={null} onClose={close} onState={onState} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -111,6 +111,7 @@ function renderModal(ui: ReactNode) {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
window.history.replaceState(null, "", "/");
|
||||
started.length = 0;
|
||||
loaded.length = 0;
|
||||
seeks.length = 0;
|
||||
|
||||
@@ -68,6 +68,7 @@ import {
|
||||
import { canFullscreenElements, fullscreenElement } from "../../lib/fullscreen";
|
||||
import { dispatch, isActivationTarget, setScopeCoveredProbe, useKeyScope } from "../../lib/keymap";
|
||||
import { useBackToClose } from "../../lib/history";
|
||||
import { linkedPlayer, playerParamUpdates, type PlayerPanelTab } from "../../lib/playerUrlState";
|
||||
import ChannelNameLink from "../video/ChannelNameLink";
|
||||
import Toaster from "../../components/ui/Toaster";
|
||||
import { useOverlayOrder } from "../../components/ui/overlayOrder";
|
||||
@@ -222,8 +223,6 @@ export default function PlayerModal({
|
||||
const { openChannel } = useNavigationActions();
|
||||
const qc = useQueryClient();
|
||||
const { ref: descFadeRef, style: descFadeStyle } = useScrollFade();
|
||||
// Browser/mouse Back closes the player instead of leaving the page.
|
||||
useBackToClose(onClose);
|
||||
// Freeze the launch queue: the player steps through the SAME filtered + sorted list it was
|
||||
// opened with, for its whole session. The live feed refetches and reorders/drops items under
|
||||
// us — e.g. auto-marking each video watched near its end removes it from a filtered feed — which
|
||||
@@ -277,6 +276,25 @@ export default function PlayerModal({
|
||||
setRelatedOf(active.id);
|
||||
setRelated(null);
|
||||
}
|
||||
// An address that names the item this player opens on (a reload, a bookmark) names its view too: the
|
||||
// related video playing in place over it and the panel's tab come back with it. Read once.
|
||||
const [linked] = useState(() => {
|
||||
const here = linkedPlayer();
|
||||
return here && here.watch === video.id ? here : null;
|
||||
});
|
||||
const linkedRelated = useQuery({
|
||||
queryKey: qk.videoRow(linked?.related ?? ""),
|
||||
queryFn: () => api.videoRow(linked!.related!),
|
||||
enabled: !!linked?.related,
|
||||
retry: false,
|
||||
});
|
||||
const [relatedAdopted, setRelatedAdopted] = useState(false);
|
||||
if (!relatedAdopted && linkedRelated.data) {
|
||||
setRelatedAdopted(true);
|
||||
// Only over the item it was named with: a step of the queue before it arrived left that one.
|
||||
if (active.id === linked?.watch && !related)
|
||||
setRelated({ video: linkedRelated.data, startAt: null });
|
||||
}
|
||||
const subject = related?.video ?? active;
|
||||
const subjectResume = related
|
||||
? (related.startAt ?? related.video.position_seconds ?? 0)
|
||||
@@ -753,7 +771,7 @@ export default function PlayerModal({
|
||||
// take the side panel's place of the description, a tab beside it; stacked, a drawer over the video
|
||||
// opened from a button beside the ⓘ. The list is the OPENED video's: one played from it leaves the
|
||||
// list in place, to try the next one (the owner's pick).
|
||||
const [panelTab, setPanelTab] = useState<"description" | "related">("description");
|
||||
const [panelTab, setPanelTab] = useState<PlayerPanelTab>(linked?.panel ?? "description");
|
||||
const [relatedOpen, setRelatedOpen] = useState(false);
|
||||
// The drawer is the stacked layout's: going wide while it is open, the list moves into the panel's
|
||||
// tab instead. Left open, it reopened unasked when the window narrowed again.
|
||||
@@ -762,6 +780,17 @@ export default function PlayerModal({
|
||||
setPanelTab("related");
|
||||
}
|
||||
const panelTabs = useTabPanel();
|
||||
// Browser/mouse Back closes the player instead of leaving the page. Its entry's address names what
|
||||
// it plays (the queue's item, a related one over it) and the wide panel's tab, so a reload or a
|
||||
// bookmark reopens it as it is; the page's own entry under it never carries them.
|
||||
useBackToClose(
|
||||
onClose,
|
||||
playerParamUpdates({
|
||||
watch: active.id,
|
||||
related: related?.video.id ?? null,
|
||||
panel: wide ? panelTab : "description",
|
||||
}),
|
||||
);
|
||||
// Turned off on this instance (a daily limit of 0): no tab, no button — nothing that could only
|
||||
// be refused.
|
||||
const relatedOn = useMe().related_enabled;
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The URL is the view: the YouTube player names what it plays in its own history entry — the item of
|
||||
// the queue, a related video played in place over it, the wide panel's tab — so a reload or a bookmark
|
||||
// reopens it as it was, and Back lands on the page's address without it. Its own file: the overlay
|
||||
// history is module state, and these read the address the closed players of other tests step back over.
|
||||
//
|
||||
// `window.YT` is a fake installed before the player's API loader first runs (see PlayerModal.dom.test).
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
|
||||
}));
|
||||
vi.mock("../navigation/navigationContext", () => ({
|
||||
useNavigationActions: () => ({ openChannel: vi.fn() }),
|
||||
}));
|
||||
vi.mock("../video/AddToPlaylist", () => ({ default: () => null }));
|
||||
vi.mock("../downloads/DownloadButton", () => ({ default: () => null }));
|
||||
|
||||
import PlayerModal from "./PlayerModal";
|
||||
import { api, type Me, type Video } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
const started: { videoId: string; start: number | undefined }[] = [];
|
||||
let playerEvents: { onReady?: () => void } = {};
|
||||
const fakePlayer = new Proxy(
|
||||
{ getCurrentTime: () => 0, getDuration: () => 3000, getVideoData: () => null },
|
||||
{ get: (t, k) => (k in t ? t[k as keyof typeof t] : k === "then" ? undefined : () => undefined) },
|
||||
);
|
||||
window.YT = {
|
||||
Player: function (
|
||||
_el: unknown,
|
||||
opts: { videoId: string; playerVars: { start?: number }; events?: typeof playerEvents },
|
||||
) {
|
||||
started.push({ videoId: opts.videoId, start: opts.playerVars.start });
|
||||
playerEvents = opts.events ?? {};
|
||||
return fakePlayer;
|
||||
} as never,
|
||||
};
|
||||
const lastStart = () => started[started.length - 1];
|
||||
|
||||
const vid = (id: string, position_seconds = 0): Video =>
|
||||
({
|
||||
id,
|
||||
title: `Video ${id}`,
|
||||
channel_id: "UCx",
|
||||
channel_title: "Channel",
|
||||
status: "new",
|
||||
position_seconds,
|
||||
duration_seconds: 3000,
|
||||
watch_url: `https://www.youtube.com/watch?v=${id}`,
|
||||
}) as unknown as Video;
|
||||
const related = (id: string, position_seconds = 0) => ({
|
||||
...vid(id, position_seconds),
|
||||
followed: false,
|
||||
});
|
||||
|
||||
// The wide layout (the side panel with its tabs): jsdom has no matchMedia, so one is put in.
|
||||
function wideWindow() {
|
||||
window.matchMedia = ((q: string) => ({
|
||||
matches: q === "(min-width: 1280px)",
|
||||
media: q,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
})) as unknown as typeof window.matchMedia;
|
||||
}
|
||||
|
||||
function renderModal(video: Video, queue: Video[]) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.me(), { related_enabled: true } as Me);
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PlayerModal
|
||||
video={video}
|
||||
queue={queue}
|
||||
onClose={() => {}}
|
||||
onState={() => Promise.resolve()}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
// A closed player steps back over its entry a task later (jsdom's back(), like a browser's).
|
||||
async function closeAndLand(unmount: () => void) {
|
||||
const landed = new Promise((r) => window.addEventListener("popstate", r, { once: true }));
|
||||
unmount();
|
||||
await act(() => landed);
|
||||
}
|
||||
|
||||
const here = () => new URLSearchParams(window.location.search);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
vi.spyOn(api, "related").mockResolvedValue({
|
||||
items: [related("rel1"), related("rel2", 120)],
|
||||
has_more: false,
|
||||
next_cursor: null,
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete (window as { matchMedia?: unknown }).matchMedia;
|
||||
started.length = 0;
|
||||
});
|
||||
|
||||
describe("the player's address", () => {
|
||||
it("names the item playing now in its own entry, a step of the queue too", async () => {
|
||||
window.history.replaceState({ sfPage: "feed" }, "", "/?q=talks");
|
||||
const r = renderModal(vid("A"), [vid("A"), vid("B")]);
|
||||
await waitFor(() => expect(here().get("watch")).toBe("A"));
|
||||
expect(here().get("q")).toBe("talks");
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "player.next" })[0]!);
|
||||
await waitFor(() => expect(here().get("watch")).toBe("B"));
|
||||
await closeAndLand(r.unmount);
|
||||
expect(window.location.search).toBe("?q=talks");
|
||||
});
|
||||
|
||||
it("wide: names the related video playing over it and the panel's tab, and Back drops it", async () => {
|
||||
wideWindow();
|
||||
window.history.replaceState({ sfPage: "feed" }, "", "/");
|
||||
const r = renderModal(vid("A", 40), [vid("A", 40)]);
|
||||
act(() => playerEvents.onReady?.());
|
||||
fireEvent.click(screen.getByRole("tab", { name: "player.related.title" }));
|
||||
await waitFor(() => expect(here().get("wtab")).toBe("related"));
|
||||
const rows = await screen.findAllByRole("link", { name: "Video rel2" });
|
||||
fireEvent.click(rows.find((a) => a.hasAttribute("data-nav-open"))!);
|
||||
await waitFor(() => expect(here().get("wrel")).toBe("rel2"));
|
||||
expect(here().get("watch")).toBe("A");
|
||||
fireEvent.click(screen.getByRole("button", { name: "player.back" }));
|
||||
await waitFor(() => expect(here().has("wrel")).toBe(false));
|
||||
expect(here().get("watch")).toBe("A");
|
||||
await closeAndLand(r.unmount);
|
||||
expect(window.location.search).toBe("");
|
||||
});
|
||||
|
||||
it("wide: a link's related video and panel tab come back over the item it names", async () => {
|
||||
wideWindow();
|
||||
window.history.replaceState({ sfPage: "feed" }, "", "/?watch=A&wrel=rel2&wtab=related");
|
||||
const row = vi.spyOn(api, "videoRow").mockResolvedValue(vid("rel2", 120));
|
||||
const r = renderModal(vid("A", 40), [vid("A", 40)]);
|
||||
await waitFor(() => expect(lastStart()).toEqual({ videoId: "rel2", start: 120 }));
|
||||
expect(row).toHaveBeenCalledWith("rel2");
|
||||
const tab = screen.getByRole("tab", { name: "player.related.title" });
|
||||
expect(tab.getAttribute("aria-selected")).toBe("true");
|
||||
expect(screen.getByRole("button", { name: "player.back" })).toBeTruthy();
|
||||
expect(here().get("wrel")).toBe("rel2");
|
||||
await closeAndLand(r.unmount);
|
||||
// The entry the link opened at named the player; once the player is gone, so is its name.
|
||||
expect(window.location.search).toBe("");
|
||||
});
|
||||
|
||||
it("wide: a link naming another item brings none of its view", async () => {
|
||||
wideWindow();
|
||||
window.history.replaceState({ sfPage: "feed" }, "", "/?watch=Z&wrel=rel2&wtab=related");
|
||||
const row = vi.spyOn(api, "videoRow").mockResolvedValue(vid("rel2", 120));
|
||||
const r = renderModal(vid("A", 40), [vid("A", 40)]);
|
||||
await waitFor(() => expect(here().get("watch")).toBe("A"));
|
||||
expect(row).not.toHaveBeenCalled();
|
||||
expect(here().has("wrel")).toBe(false);
|
||||
expect(here().has("wtab")).toBe(false);
|
||||
expect(lastStart()?.videoId).toBe("A");
|
||||
await closeAndLand(r.unmount);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A reload or a bookmark of the YouTube player reopens it: the page that hosts it opens it once its list
|
||||
// is in — from the list, so it plays on through it, or alone from the catalog — and a page that hosts
|
||||
// none gets the shell's, the video alone. A video the catalog no longer has is said, not swallowed.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, renderHook, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
|
||||
}));
|
||||
vi.mock("../../lib/actionErrors", () => ({ notifyActionError: vi.fn() }));
|
||||
// The player itself is not what is under test: it shows what it was opened on.
|
||||
vi.mock("./PlayerModal", () => ({
|
||||
default: ({ video, queue }: { video: Video; queue?: Video[] }) => (
|
||||
<p>
|
||||
playing {video.id} {queue ? "in a list" : "alone"}
|
||||
</p>
|
||||
),
|
||||
}));
|
||||
|
||||
import { useOpenLinkedPlayer } from "./useOpenLinkedPlayer";
|
||||
import { LinkedPlayer } from "./LinkedPlayer";
|
||||
import { api, type Video } from "../../lib/api";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
|
||||
const vid = (id: string) => ({ id, title: id }) as unknown as Video;
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
vi.mocked(notifyActionError).mockClear();
|
||||
window.history.replaceState(null, "", "/");
|
||||
});
|
||||
|
||||
describe("useOpenLinkedPlayer", () => {
|
||||
it("opens the item from the host's list once the list is in, and only once", async () => {
|
||||
window.history.replaceState(null, "", "/?watch=B");
|
||||
const row = vi.spyOn(api, "videoRow");
|
||||
const open = vi.fn();
|
||||
const list = [vid("A"), vid("B")];
|
||||
const { rerender } = renderHook(
|
||||
({ ready }) => useOpenLinkedPlayer(ready, (id) => list.find((v) => v.id === id), open),
|
||||
{ wrapper, initialProps: { ready: false } },
|
||||
);
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
rerender({ ready: true });
|
||||
await waitFor(() => expect(open).toHaveBeenCalledWith(list[1], true));
|
||||
rerender({ ready: true });
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
expect(row).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens an item the list does not hold alone, its row from the catalog", async () => {
|
||||
window.history.replaceState(null, "", "/?watch=Z");
|
||||
vi.spyOn(api, "videoRow").mockResolvedValue(vid("Z"));
|
||||
const open = vi.fn();
|
||||
renderHook(() => useOpenLinkedPlayer(true, () => undefined, open), { wrapper });
|
||||
await waitFor(() => expect(open).toHaveBeenCalledWith(vid("Z"), false));
|
||||
});
|
||||
|
||||
it("says so when the catalog no longer has it", async () => {
|
||||
window.history.replaceState(null, "", "/?watch=Z");
|
||||
const gone = new Error("not found");
|
||||
vi.spyOn(api, "videoRow").mockRejectedValue(gone);
|
||||
const open = vi.fn();
|
||||
renderHook(() => useOpenLinkedPlayer(true, () => undefined, open), { wrapper });
|
||||
await waitFor(() =>
|
||||
expect(notifyActionError).toHaveBeenCalledWith(gone, "player.linkedFailed"),
|
||||
);
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens nothing, and asks for nothing, for an address that names no player", async () => {
|
||||
window.history.replaceState(null, "", "/?q=talks");
|
||||
const row = vi.spyOn(api, "videoRow");
|
||||
const open = vi.fn();
|
||||
renderHook(() => useOpenLinkedPlayer(true, () => undefined, open), { wrapper });
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
expect(row).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the shell's LinkedPlayer", () => {
|
||||
it("plays the video alone over a page that hosts no player", async () => {
|
||||
window.history.replaceState(null, "", "/?page=channels&watch=Z");
|
||||
vi.spyOn(api, "videoRow").mockResolvedValue(vid("Z"));
|
||||
render(<LinkedPlayer hosted={false} />, { wrapper });
|
||||
expect(await screen.findByText("playing Z alone")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("leaves it to a page that hosts its own", async () => {
|
||||
window.history.replaceState(null, "", "/?watch=Z");
|
||||
const row = vi.spyOn(api, "videoRow").mockResolvedValue(vid("Z"));
|
||||
render(<LinkedPlayer hosted />, { wrapper });
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(row).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText(/playing/)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useEffect, useEffectEvent, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api, type Video } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { linkedPlayer } from "../../lib/playerUrlState";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
|
||||
/** Opens the player an address names (a reload, a bookmark — lib/playerUrlState) on the page that
|
||||
* hosts it, once: read as the host mounts, opened once its list is in (`ready`). From the list when the
|
||||
* item is in it (`find`), so the player plays on through the list; else alone, its row fetched from
|
||||
* the catalog. A row the catalog no longer has is said, not swallowed. */
|
||||
export function useOpenLinkedPlayer(
|
||||
ready: boolean,
|
||||
find: (id: string) => Video | undefined,
|
||||
open: (video: Video, inList: boolean) => void,
|
||||
): void {
|
||||
const { t } = useTranslation();
|
||||
const [linked] = useState(() => linkedPlayer()?.watch ?? null);
|
||||
// Opened (or said to be gone) once; a ref, as nothing on screen reads it.
|
||||
const done = useRef(linked == null);
|
||||
const listed = ready && linked != null ? find(linked) : undefined;
|
||||
const row = useQuery({
|
||||
queryKey: qk.videoRow(linked ?? ""),
|
||||
queryFn: () => api.videoRow(linked!),
|
||||
enabled: ready && linked != null && !listed,
|
||||
retry: false,
|
||||
});
|
||||
const openIt = useEffectEvent(open);
|
||||
const failed = useEffectEvent((err: unknown) => notifyActionError(err, t("player.linkedFailed")));
|
||||
useEffect(() => {
|
||||
if (done.current) return;
|
||||
if (listed) openIt(listed, true);
|
||||
else if (row.data) openIt(row.data, false);
|
||||
else if (row.isError) failed(row.error);
|
||||
else return;
|
||||
done.current = true;
|
||||
}, [listed, row.data, row.isError, row.error]);
|
||||
}
|
||||
Reference in New Issue
Block a user