forked from farhoodlabs/paperclip
Merge public-gh/master into fix/hmr-websocket-reverse-proxy
Reconcile the PR with current master, preserve both PWA capability meta tags, and add websocket lifecycle coverage for the StrictMode-safe live updates fix. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { __liveUpdatesTestUtils } from "./LiveUpdatesProvider";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
|
||||
describe("LiveUpdatesProvider issue invalidation", () => {
|
||||
it("refreshes touched inbox queries for issue activity", () => {
|
||||
const invalidations: unknown[] = [];
|
||||
const queryClient = {
|
||||
invalidateQueries: (input: unknown) => {
|
||||
invalidations.push(input);
|
||||
},
|
||||
getQueryData: () => undefined,
|
||||
};
|
||||
|
||||
__liveUpdatesTestUtils.invalidateActivityQueries(
|
||||
queryClient as never,
|
||||
"company-1",
|
||||
{
|
||||
entityType: "issue",
|
||||
entityId: "issue-1",
|
||||
details: null,
|
||||
},
|
||||
);
|
||||
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: queryKeys.issues.listMineByMe("company-1"),
|
||||
});
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: queryKeys.issues.listTouchedByMe("company-1"),
|
||||
});
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: queryKeys.issues.listUnreadTouchedByMe("company-1"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("LiveUpdatesProvider visible issue toast suppression", () => {
|
||||
it("suppresses activity toasts for the issue page currently in view", () => {
|
||||
const queryClient = {
|
||||
getQueryData: (key: unknown) => {
|
||||
if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.detail("PAP-759"))) {
|
||||
return {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-759",
|
||||
assigneeAgentId: "agent-1",
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
__liveUpdatesTestUtils.shouldSuppressActivityToastForVisibleIssue(
|
||||
queryClient as never,
|
||||
"/PAP/issues/PAP-759",
|
||||
{
|
||||
entityType: "issue",
|
||||
entityId: "issue-1",
|
||||
details: { identifier: "PAP-759" },
|
||||
},
|
||||
{ isForegrounded: true },
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
__liveUpdatesTestUtils.shouldSuppressActivityToastForVisibleIssue(
|
||||
queryClient as never,
|
||||
"/PAP/issues/PAP-759",
|
||||
{
|
||||
entityType: "issue",
|
||||
entityId: "issue-2",
|
||||
details: { identifier: "PAP-760" },
|
||||
},
|
||||
{ isForegrounded: true },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("suppresses run and agent status toasts for the assignee of the visible issue", () => {
|
||||
const queryClient = {
|
||||
getQueryData: (key: unknown) => {
|
||||
if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.detail("PAP-759"))) {
|
||||
return {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-759",
|
||||
assigneeAgentId: "agent-1",
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
__liveUpdatesTestUtils.shouldSuppressRunStatusToastForVisibleIssue(
|
||||
queryClient as never,
|
||||
"/PAP/issues/PAP-759",
|
||||
{
|
||||
runId: "run-1",
|
||||
agentId: "agent-1",
|
||||
},
|
||||
{ isForegrounded: true },
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
__liveUpdatesTestUtils.shouldSuppressAgentStatusToastForVisibleIssue(
|
||||
queryClient as never,
|
||||
"/PAP/issues/PAP-759",
|
||||
{
|
||||
agentId: "agent-1",
|
||||
status: "running",
|
||||
},
|
||||
{ isForegrounded: true },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("LiveUpdatesProvider socket helpers", () => {
|
||||
it("waits for the selected company object to catch up before connecting", () => {
|
||||
expect(__liveUpdatesTestUtils.resolveLiveCompanyId("company-1", null)).toBeNull();
|
||||
expect(__liveUpdatesTestUtils.resolveLiveCompanyId("company-1", "company-2")).toBeNull();
|
||||
expect(__liveUpdatesTestUtils.resolveLiveCompanyId("company-1", "company-1")).toBe("company-1");
|
||||
});
|
||||
|
||||
it("defers close until onopen for sockets that are still connecting", () => {
|
||||
const socket = {
|
||||
readyState: 0,
|
||||
onopen: (() => undefined) as (() => void) | null,
|
||||
onmessage: (() => undefined) as (() => void) | null,
|
||||
onerror: (() => undefined) as (() => void) | null,
|
||||
onclose: (() => undefined) as (() => void) | null,
|
||||
close: vi.fn(),
|
||||
};
|
||||
|
||||
__liveUpdatesTestUtils.closeSocketQuietly(socket as never, "provider_unmount");
|
||||
|
||||
expect(socket.close).not.toHaveBeenCalled();
|
||||
expect(socket.onmessage).toBeNull();
|
||||
expect(socket.onclose).toBeNull();
|
||||
expect(socket.onopen).toBeTypeOf("function");
|
||||
expect(socket.onerror).toBeTypeOf("function");
|
||||
|
||||
socket.onopen?.();
|
||||
|
||||
expect(socket.close).toHaveBeenCalledWith(1000, "provider_unmount");
|
||||
expect(socket.onopen).toBeNull();
|
||||
expect(socket.onerror).toBeNull();
|
||||
});
|
||||
|
||||
it("closes open sockets immediately without leaving handlers behind", () => {
|
||||
const socket = {
|
||||
readyState: 1,
|
||||
onopen: (() => undefined) as (() => void) | null,
|
||||
onmessage: (() => undefined) as (() => void) | null,
|
||||
onerror: (() => undefined) as (() => void) | null,
|
||||
onclose: (() => undefined) as (() => void) | null,
|
||||
close: vi.fn(),
|
||||
};
|
||||
|
||||
__liveUpdatesTestUtils.closeSocketQuietly(socket as never, "stale_connection");
|
||||
|
||||
expect(socket.close).toHaveBeenCalledWith(1000, "stale_connection");
|
||||
expect(socket.onopen).toBeNull();
|
||||
expect(socket.onmessage).toBeNull();
|
||||
expect(socket.onerror).toBeNull();
|
||||
expect(socket.onclose).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,30 @@
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||
import type { Agent, Issue, LiveEvent } from "@paperclipai/shared";
|
||||
import type { RunForIssue } from "../api/activity";
|
||||
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
|
||||
import { authApi } from "../api/auth";
|
||||
import { useCompany } from "./CompanyContext";
|
||||
import type { ToastInput } from "./ToastContext";
|
||||
import { useToast } from "./ToastContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { toCompanyRelativePath } from "../lib/company-routes";
|
||||
import { useLocation } from "../lib/router";
|
||||
|
||||
const TOAST_COOLDOWN_WINDOW_MS = 10_000;
|
||||
const TOAST_COOLDOWN_MAX = 3;
|
||||
const RECONNECT_SUPPRESS_MS = 2000;
|
||||
const SOCKET_CONNECTING = 0;
|
||||
const SOCKET_OPEN = 1;
|
||||
|
||||
type LiveUpdatesSocketLike = {
|
||||
readyState: number;
|
||||
onopen: ((this: WebSocket, ev: Event) => unknown) | null;
|
||||
onmessage: ((this: WebSocket, ev: MessageEvent) => unknown) | null;
|
||||
onerror: ((this: WebSocket, ev: Event) => unknown) | null;
|
||||
onclose: ((this: WebSocket, ev: CloseEvent) => unknown) | null;
|
||||
close: (code?: number, reason?: string) => void;
|
||||
};
|
||||
|
||||
function readString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
@@ -63,6 +78,16 @@ interface IssueToastContext {
|
||||
href: string;
|
||||
}
|
||||
|
||||
interface VisibleRouteOptions {
|
||||
isForegrounded?: boolean;
|
||||
}
|
||||
|
||||
interface VisibleIssueRouteContext {
|
||||
issueRefs: Set<string>;
|
||||
assigneeAgentId: string | null;
|
||||
runIds: Set<string>;
|
||||
}
|
||||
|
||||
function resolveIssueQueryRefs(
|
||||
queryClient: QueryClient,
|
||||
companyId: string,
|
||||
@@ -125,6 +150,110 @@ function resolveIssueToastContext(
|
||||
};
|
||||
}
|
||||
|
||||
function isPageForegrounded(): boolean {
|
||||
if (typeof document === "undefined") return false;
|
||||
if (document.visibilityState !== "visible") return false;
|
||||
if (typeof document.hasFocus === "function" && !document.hasFocus()) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveVisibleIssueRouteContext(
|
||||
queryClient: QueryClient,
|
||||
pathname: string,
|
||||
options?: VisibleRouteOptions,
|
||||
): VisibleIssueRouteContext | null {
|
||||
const isForegrounded = options?.isForegrounded ?? isPageForegrounded();
|
||||
if (!isForegrounded) return null;
|
||||
|
||||
const relativePath = toCompanyRelativePath(pathname);
|
||||
const segments = relativePath.split("/").filter(Boolean);
|
||||
if (segments[0] !== "issues" || !segments[1]) return null;
|
||||
|
||||
const issueRef = decodeURIComponent(segments[1]);
|
||||
const issue = queryClient.getQueryData<Issue>(queryKeys.issues.detail(issueRef)) ?? null;
|
||||
const issueRefs = new Set<string>([issueRef]);
|
||||
if (issue?.id) issueRefs.add(issue.id);
|
||||
if (issue?.identifier) issueRefs.add(issue.identifier);
|
||||
|
||||
const runIds = new Set<string>();
|
||||
const activeRun = queryClient.getQueryData<ActiveRunForIssue | null>(queryKeys.issues.activeRun(issueRef));
|
||||
const liveRuns = queryClient.getQueryData<LiveRunForIssue[]>(queryKeys.issues.liveRuns(issueRef)) ?? [];
|
||||
const linkedRuns = queryClient.getQueryData<RunForIssue[]>(queryKeys.issues.runs(issueRef)) ?? [];
|
||||
|
||||
if (activeRun?.id) runIds.add(activeRun.id);
|
||||
for (const run of liveRuns) {
|
||||
if (run.id) runIds.add(run.id);
|
||||
}
|
||||
for (const run of linkedRuns) {
|
||||
if (run.runId) runIds.add(run.runId);
|
||||
}
|
||||
|
||||
return {
|
||||
issueRefs,
|
||||
assigneeAgentId: issue?.assigneeAgentId ?? null,
|
||||
runIds,
|
||||
};
|
||||
}
|
||||
|
||||
function buildIssueRefsForPayload(entityId: string, details: Record<string, unknown> | null): Set<string> {
|
||||
const refs = new Set<string>([entityId]);
|
||||
const identifier = readString(details?.identifier) ?? readString(details?.issueIdentifier);
|
||||
if (identifier) refs.add(identifier);
|
||||
return refs;
|
||||
}
|
||||
|
||||
function overlaps(a: Set<string>, b: Set<string>): boolean {
|
||||
for (const value of a) {
|
||||
if (b.has(value)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function shouldSuppressActivityToastForVisibleIssue(
|
||||
queryClient: QueryClient,
|
||||
pathname: string,
|
||||
payload: Record<string, unknown>,
|
||||
options?: VisibleRouteOptions,
|
||||
): boolean {
|
||||
const entityType = readString(payload.entityType);
|
||||
const entityId = readString(payload.entityId);
|
||||
if (entityType !== "issue" || !entityId) return false;
|
||||
|
||||
const context = resolveVisibleIssueRouteContext(queryClient, pathname, options);
|
||||
if (!context) return false;
|
||||
|
||||
return overlaps(context.issueRefs, buildIssueRefsForPayload(entityId, readRecord(payload.details)));
|
||||
}
|
||||
|
||||
function shouldSuppressRunStatusToastForVisibleIssue(
|
||||
queryClient: QueryClient,
|
||||
pathname: string,
|
||||
payload: Record<string, unknown>,
|
||||
options?: VisibleRouteOptions,
|
||||
): boolean {
|
||||
const context = resolveVisibleIssueRouteContext(queryClient, pathname, options);
|
||||
if (!context) return false;
|
||||
|
||||
const runId = readString(payload.runId);
|
||||
if (runId && context.runIds.has(runId)) return true;
|
||||
|
||||
const agentId = readString(payload.agentId);
|
||||
return !!agentId && !!context.assigneeAgentId && agentId === context.assigneeAgentId;
|
||||
}
|
||||
|
||||
function shouldSuppressAgentStatusToastForVisibleIssue(
|
||||
queryClient: QueryClient,
|
||||
pathname: string,
|
||||
payload: Record<string, unknown>,
|
||||
options?: VisibleRouteOptions,
|
||||
): boolean {
|
||||
const context = resolveVisibleIssueRouteContext(queryClient, pathname, options);
|
||||
if (!context?.assigneeAgentId) return false;
|
||||
|
||||
const agentId = readString(payload.agentId);
|
||||
return !!agentId && agentId === context.assigneeAgentId;
|
||||
}
|
||||
|
||||
const ISSUE_TOAST_ACTIONS = new Set(["issue.created", "issue.updated", "issue.comment_added"]);
|
||||
const AGENT_TOAST_STATUSES = new Set(["running", "error"]);
|
||||
const TERMINAL_RUN_STATUSES = new Set(["succeeded", "failed", "timed_out", "cancelled"]);
|
||||
@@ -256,7 +385,7 @@ function buildJoinRequestToast(
|
||||
title: `${label} wants to join`,
|
||||
body: "A new join request is waiting for approval.",
|
||||
tone: "info",
|
||||
action: { label: "View inbox", href: "/inbox/unread" },
|
||||
action: { label: "View inbox", href: "/inbox/mine" },
|
||||
dedupeKey: `join-request:${entityId}`,
|
||||
};
|
||||
}
|
||||
@@ -361,6 +490,9 @@ function invalidateActivityQueries(
|
||||
|
||||
if (entityType === "issue") {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listMineByMe(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(companyId) });
|
||||
if (entityId) {
|
||||
const details = readRecord(payload.details);
|
||||
const issueRefs = resolveIssueQueryRefs(queryClient, companyId, entityId, details);
|
||||
@@ -420,6 +552,11 @@ function invalidateActivityQueries(
|
||||
return;
|
||||
}
|
||||
|
||||
if (entityType === "routine" || entityType === "routine_trigger" || entityType === "routine_run") {
|
||||
queryClient.invalidateQueries({ queryKey: ["routines"] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (entityType === "company") {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
||||
}
|
||||
@@ -463,6 +600,7 @@ function gatedPushToast(
|
||||
function handleLiveEvent(
|
||||
queryClient: QueryClient,
|
||||
expectedCompanyId: string,
|
||||
pathname: string,
|
||||
event: LiveEvent,
|
||||
pushToast: (toast: ToastInput) => string | null,
|
||||
gate: ToastGate,
|
||||
@@ -480,7 +618,12 @@ function handleLiveEvent(
|
||||
invalidateHeartbeatQueries(queryClient, expectedCompanyId, payload);
|
||||
if (event.type === "heartbeat.run.status") {
|
||||
const toast = buildRunStatusToast(payload, nameOf);
|
||||
if (toast) gatedPushToast(gate, pushToast, "run-status", toast);
|
||||
if (
|
||||
toast &&
|
||||
!shouldSuppressRunStatusToastForVisibleIssue(queryClient, pathname, payload)
|
||||
) {
|
||||
gatedPushToast(gate, pushToast, "run-status", toast);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -496,7 +639,12 @@ function handleLiveEvent(
|
||||
const agentId = readString(payload.agentId);
|
||||
if (agentId) queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId) });
|
||||
const toast = buildAgentStatusToast(payload, nameOf, queryClient, expectedCompanyId);
|
||||
if (toast) gatedPushToast(gate, pushToast, "agent-status", toast);
|
||||
if (
|
||||
toast &&
|
||||
!shouldSuppressAgentStatusToastForVisibleIssue(queryClient, pathname, payload)
|
||||
) {
|
||||
gatedPushToast(gate, pushToast, "agent-status", toast);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -506,15 +654,70 @@ function handleLiveEvent(
|
||||
const toast =
|
||||
buildActivityToast(queryClient, expectedCompanyId, payload, currentActor) ??
|
||||
buildJoinRequestToast(payload);
|
||||
if (toast) gatedPushToast(gate, pushToast, `activity:${action ?? "unknown"}`, toast);
|
||||
if (
|
||||
toast &&
|
||||
!shouldSuppressActivityToastForVisibleIssue(queryClient, pathname, payload)
|
||||
) {
|
||||
gatedPushToast(gate, pushToast, `activity:${action ?? "unknown"}`, toast);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveLiveCompanyId(
|
||||
selectedCompanyId: string | null,
|
||||
selectedCompanyLiveId: string | null,
|
||||
): string | null {
|
||||
return selectedCompanyId && selectedCompanyId === selectedCompanyLiveId
|
||||
? selectedCompanyId
|
||||
: null;
|
||||
}
|
||||
|
||||
function resetSocketHandlers(target: LiveUpdatesSocketLike) {
|
||||
target.onopen = null;
|
||||
target.onmessage = null;
|
||||
target.onerror = null;
|
||||
target.onclose = null;
|
||||
}
|
||||
|
||||
function closeSocketQuietly(target: LiveUpdatesSocketLike | null, reason: string) {
|
||||
if (!target) return;
|
||||
|
||||
if (target.readyState === SOCKET_CONNECTING) {
|
||||
// Let the handshake complete and then close. Calling close() while the
|
||||
// socket is still CONNECTING is what triggers the noisy browser error.
|
||||
target.onopen = () => {
|
||||
resetSocketHandlers(target);
|
||||
target.close(1000, reason);
|
||||
};
|
||||
target.onmessage = null;
|
||||
target.onerror = () => undefined;
|
||||
target.onclose = null;
|
||||
return;
|
||||
}
|
||||
|
||||
resetSocketHandlers(target);
|
||||
|
||||
if (target.readyState === SOCKET_OPEN) {
|
||||
target.close(1000, reason);
|
||||
}
|
||||
}
|
||||
|
||||
export const __liveUpdatesTestUtils = {
|
||||
closeSocketQuietly,
|
||||
invalidateActivityQueries,
|
||||
resolveLiveCompanyId,
|
||||
shouldSuppressActivityToastForVisibleIssue,
|
||||
shouldSuppressRunStatusToastForVisibleIssue,
|
||||
shouldSuppressAgentStatusToastForVisibleIssue,
|
||||
};
|
||||
|
||||
export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
|
||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
const location = useLocation();
|
||||
const gateRef = useRef<ToastGate>({ cooldownHits: new Map(), suppressUntil: 0 });
|
||||
const pathnameRef = useRef(location.pathname);
|
||||
const { data: session, status: sessionStatus } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
@@ -522,13 +725,17 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
|
||||
});
|
||||
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||
const socketAuthKey = session?.session?.id ?? currentUserId ?? "signed_out";
|
||||
const liveCompanyId = selectedCompany?.id === selectedCompanyId ? selectedCompanyId : null;
|
||||
const liveCompanyId = resolveLiveCompanyId(selectedCompanyId, selectedCompany?.id ?? null);
|
||||
const canConnectSocket = sessionStatus === "success" && session !== null && liveCompanyId !== null;
|
||||
const currentActorRef = useRef<{ userId: string | null; agentId: string | null }>({
|
||||
userId: currentUserId,
|
||||
agentId: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
pathnameRef.current = location.pathname;
|
||||
}, [location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
currentActorRef.current = {
|
||||
userId: currentUserId,
|
||||
@@ -543,7 +750,6 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
|
||||
let reconnectAttempt = 0;
|
||||
let reconnectTimer: number | null = null;
|
||||
let socket: WebSocket | null = null;
|
||||
const noop = () => undefined;
|
||||
|
||||
const clearReconnect = () => {
|
||||
if (reconnectTimer !== null) {
|
||||
@@ -552,35 +758,6 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
};
|
||||
|
||||
const closeSocketQuietly = (target: WebSocket | null, reason: string) => {
|
||||
if (!target) return;
|
||||
|
||||
if (target.readyState === WebSocket.CONNECTING) {
|
||||
// Let the handshake complete and then close. Calling close() while the
|
||||
// socket is still CONNECTING is what triggers the noisy browser error.
|
||||
target.onopen = () => {
|
||||
target.onopen = null;
|
||||
target.onmessage = null;
|
||||
target.onerror = null;
|
||||
target.onclose = null;
|
||||
target.close(1000, reason);
|
||||
};
|
||||
target.onmessage = null;
|
||||
target.onerror = noop;
|
||||
target.onclose = null;
|
||||
return;
|
||||
}
|
||||
|
||||
target.onopen = null;
|
||||
target.onmessage = null;
|
||||
target.onerror = null;
|
||||
target.onclose = null;
|
||||
|
||||
if (target.readyState === WebSocket.OPEN) {
|
||||
target.close(1000, reason);
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleReconnect = () => {
|
||||
if (closed) return;
|
||||
reconnectAttempt += 1;
|
||||
@@ -615,7 +792,7 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as LiveEvent;
|
||||
handleLiveEvent(queryClient, liveCompanyId, parsed, pushToast, gateRef.current, {
|
||||
handleLiveEvent(queryClient, liveCompanyId, pathnameRef.current, parsed, pushToast, gateRef.current, {
|
||||
userId: currentActorRef.current.userId,
|
||||
agentId: currentActorRef.current.agentId,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user