refactor(ui): simplify browser access and media recovery (#148811)

This commit is contained in:
Peter Steinberger 2026-09-14 23:08:58 -07:00 • committed by GitHub
parent 5cf5317dd2
commit 3b5258365c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 134 additions and 171 deletions

View file

@ -3756,7 +3756,7 @@ ui/src/pages/chat/components/chat-header-session-menu.ts 1
ui/src/pages/chat/components/chat-message-attachment-availability.ts 1
ui/src/pages/chat/components/chat-message-bubble.ts 1
ui/src/pages/chat/components/chat-message-confirmation.ts 2
ui/src/pages/chat/components/chat-message-media.ts 3
ui/src/pages/chat/components/chat-message-media.ts 1
ui/src/pages/chat/components/chat-message-timestamp.ts 4
ui/src/pages/chat/components/chat-model-picker-options.ts 2
ui/src/pages/chat/components/chat-model-picker.ts 5

View file

@ -1,6 +1,3 @@
// OpenClaw Control – Service Worker
// Handles offline caching and push notifications.
const CACHE_PREFIX = "openclaw-control-";
const EMBEDDED_CACHE_VERSION = "__OPENCLAW_CONTROL_UI_BUILD_ID__";
const URL_CACHE_VERSION = new URL(self.location.href).searchParams
@ -12,17 +9,17 @@ const CACHE_VERSION =
: URL_CACHE_VERSION) || "dev";
const CACHE_NAME = `${CACHE_PREFIX}${CACHE_VERSION}`;
const CONTROL_CACHE_LIMIT = 3;
const SCOPE_URL = new URL(self.registration.scope);
const SCOPE_PATH = SCOPE_URL.pathname.endsWith("/") ? SCOPE_URL.pathname : `${SCOPE_URL.pathname}/`;
function controlUiPathname(url) {
const scopeUrl = new URL(self.registration.scope);
const scopePath = scopeUrl.pathname.endsWith("/") ? scopeUrl.pathname : `${scopeUrl.pathname}/`;
if (url.origin !== scopeUrl.origin) {
if (url.origin !== SCOPE_URL.origin) {
return null;
}
if (url.pathname === scopeUrl.pathname) {
if (url.pathname === SCOPE_URL.pathname) {
return "/";
}
return url.pathname.startsWith(scopePath) ? `/${url.pathname.slice(scopePath.length)}` : null;
return url.pathname.startsWith(SCOPE_PATH) ? `/${url.pathname.slice(SCOPE_PATH.length)}` : null;
}
// Older pages reload directly and cannot acquire new config-draft guards. Keep
@ -40,7 +37,6 @@ self.addEventListener("message", (event) => {
}
});
// Minimal app-shell files to precache.
const PRECACHE_URLS = ["./"];
self.addEventListener("install", (event) => {
@ -186,27 +182,21 @@ self.addEventListener("push", (event) => {
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const scopeUrl = new URL(self.registration.scope);
const scopePath = scopeUrl.pathname.endsWith("/") ? scopeUrl.pathname : `${scopeUrl.pathname}/`;
// Relative targets belong beneath the registered scope even when its URL
// omits a trailing slash; keep the exact scope for default navigation.
const scopeNavigationBase = new URL(scopePath, scopeUrl);
const scopeNavigationBase = new URL(SCOPE_PATH, SCOPE_URL);
const notificationUrl = event.notification.data?.url;
// Notifications shown before an update stored "./" for implicit targets.
// Preserve their existing in-scope tabs when the new worker handles the click.
const hasExplicitTarget =
event.notification.data?.explicitUrl ?? Boolean(notificationUrl && notificationUrl !== "./");
const isInScope = (url) =>
url.origin === scopeUrl.origin &&
(url.pathname === scopeUrl.pathname || url.pathname.startsWith(scopePath));
let targetUrl = scopeUrl;
let targetUrl = SCOPE_URL;
try {
const requestedUrl = new URL(
(hasExplicitTarget ? notificationUrl : undefined) || scopeUrl.href,
(hasExplicitTarget ? notificationUrl : undefined) || SCOPE_URL.href,
scopeNavigationBase,
);
if (isInScope(requestedUrl)) {
if (controlUiPathname(requestedUrl) !== null) {
targetUrl = requestedUrl;
}
} catch {
@ -225,7 +215,7 @@ self.addEventListener("notificationclick", (event) => {
continue;
}
if (!isInScope(clientUrl)) {
if (controlUiPathname(clientUrl) === null) {
continue;
}
if (!hasExplicitTarget) {

View file

@ -1,5 +1,6 @@
/* @vitest-environment jsdom */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from "vitest";
import { createDeferred } from "../../../test/helpers/promise.js";
import { getRenderedModalDialog, installDialogPolyfill } from "../test-helpers/modal-dialog.ts";
import { startBrowserAuthRecovery } from "./browser-auth-recovery.ts";
import { fetchControlUiResource, subscribeBrowserAuthRestored } from "./browser-http.ts";
@ -52,48 +53,44 @@ describe("browser sign-in recovery", () => {
});
vi.stubGlobal("fetch", fetchMock);
const restored = vi.fn();
const unsubscribe = subscribeBrowserAuthRestored(restored);
onTestFinished(subscribeBrowserAuthRestored(restored));
const opened = vi.spyOn(window, "open").mockReturnValue(null);
const initialLocation = window.location.href;
try {
const requests = ["image-a", "image-b"].map((name) =>
fetchControlUiResource(`/nested/__openclaw__/assistant-media?source=${name}`),
);
const results = await Promise.allSettled(requests);
expect(results.every((result) => result.status === "rejected")).toBe(true);
await expect.poll(() => document.querySelector("openclaw-modal-dialog")).not.toBeNull();
const { dialog } = await getRenderedModalDialog(document.body);
expect(dialog.getAttribute("aria-label")).toBe("Sign in to continue loading content");
expect(document.querySelectorAll("openclaw-modal-dialog")).toHaveLength(1);
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "HEAD")).toEqual([
[
`${window.location.origin}/nested/control-ui-config.json`,
expect.objectContaining({
redirect: "manual",
cache: "no-store",
credentials: "same-origin",
}),
],
]);
const requests = ["image-a", "image-b"].map((name) =>
fetchControlUiResource(`/nested/__openclaw__/assistant-media?source=${name}`),
);
const results = await Promise.allSettled(requests);
expect(results.every((result) => result.status === "rejected")).toBe(true);
await expect.poll(() => document.querySelector("openclaw-modal-dialog")).not.toBeNull();
const { dialog } = await getRenderedModalDialog(document.body);
expect(dialog.getAttribute("aria-label")).toBe("Sign in to continue loading content");
expect(document.querySelectorAll("openclaw-modal-dialog")).toHaveLength(1);
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "HEAD")).toEqual([
[
`${window.location.origin}/nested/control-ui-config.json`,
expect.objectContaining({
redirect: "manual",
cache: "no-store",
credentials: "same-origin",
}),
],
]);
button("Sign in").click();
expect(opened).toHaveBeenCalledExactlyOnceWith(
`${window.location.origin}/nested/`,
"_blank",
"noopener,noreferrer",
);
button("Check again").click();
await expect.poll(() => document.body.textContent).toContain("Sign-in is still required");
expect(restored).not.toHaveBeenCalled();
button("Sign in").click();
expect(opened).toHaveBeenCalledExactlyOnceWith(
`${window.location.origin}/nested/`,
"_blank",
"noopener,noreferrer",
);
button("Check again").click();
await expect.poll(() => document.body.textContent).toContain("Sign-in is still required");
expect(restored).not.toHaveBeenCalled();
authenticated = true;
window.dispatchEvent(new Event("focus"));
await expect.poll(() => restored.mock.calls.length).toBe(1);
expect(document.querySelector("openclaw-modal-dialog")).toBeNull();
expect(window.location.href).toBe(initialLocation);
} finally {
unsubscribe();
}
authenticated = true;
window.dispatchEvent(new Event("focus"));
await expect.poll(() => restored.mock.calls.length).toBe(1);
expect(document.querySelector("openclaw-modal-dialog")).toBeNull();
expect(window.location.href).toBe(initialLocation);
});
it.each(["offline", "missing", "server-error", "gateway-auth", "html"])(
@ -155,24 +152,20 @@ describe("browser sign-in recovery", () => {
}),
);
const restored = vi.fn();
const unsubscribe = subscribeBrowserAuthRestored(restored);
try {
await expect(
fetchControlUiResource("/nested/__openclaw__/assistant-media"),
).rejects.toThrow();
await getRenderedModalDialog(document.body);
signedIn = true;
window.dispatchEvent(new Event("focus"));
await expect.poll(() => restored.mock.calls.length).toBe(1);
expect(credentials).toEqual([
"Bearer expired-device-token",
"Bearer stored-token",
...(acceptedCredential === "stored-password" ? ["Bearer stored-password"] : []),
]);
expect(document.querySelector("openclaw-modal-dialog")).toBeNull();
} finally {
unsubscribe();
}
onTestFinished(subscribeBrowserAuthRestored(restored));
await expect(
fetchControlUiResource("/nested/__openclaw__/assistant-media"),
).rejects.toThrow();
await getRenderedModalDialog(document.body);
signedIn = true;
window.dispatchEvent(new Event("focus"));
await expect.poll(() => restored.mock.calls.length).toBe(1);
expect(credentials).toEqual([
"Bearer expired-device-token",
"Bearer stored-token",
...(acceptedCredential === "stored-password" ? ["Bearer stored-password"] : []),
]);
expect(document.querySelector("openclaw-modal-dialog")).toBeNull();
},
);
@ -181,7 +174,7 @@ describe("browser sign-in recovery", () => {
let token = "original-token";
vi.spyOn(window, "open").mockReturnValue(null);
stop = startBrowserAuthRecovery("/nested", () => ({ settings: { token } }));
let finishProbe!: (response: Response) => void;
const probe = createDeferred<Response>();
const success = () => new Response(null, { headers: { "content-type": "application/json" } });
let probes = 0;
vi.stubGlobal(
@ -195,33 +188,25 @@ describe("browser sign-in recovery", () => {
return redirectResponse();
}
if (probes === 2) {
return new Promise<Response>((resolve) => {
finishProbe = resolve;
});
return probe.promise;
}
expect(new Headers(init.headers).get("Authorization")).toBe("Bearer replacement-token");
return success();
}),
);
const restored = vi.fn();
const unsubscribe = subscribeBrowserAuthRestored(restored);
try {
await expect(
fetchControlUiResource("/nested/__openclaw__/assistant-media"),
).rejects.toThrow();
await getRenderedModalDialog(document.body);
button("Sign in").click();
window.dispatchEvent(new Event("focus"));
token = "replacement-token";
finishProbe(success());
await expect.poll(() => button("Check again").disabled).toBe(false);
expect(restored).not.toHaveBeenCalled();
expect(document.querySelector("openclaw-modal-dialog")).not.toBeNull();
button("Check again").click();
await expect.poll(() => restored.mock.calls.length).toBe(1);
} finally {
unsubscribe();
}
onTestFinished(subscribeBrowserAuthRestored(restored));
await expect(fetchControlUiResource("/nested/__openclaw__/assistant-media")).rejects.toThrow();
await getRenderedModalDialog(document.body);
button("Sign in").click();
window.dispatchEvent(new Event("focus"));
token = "replacement-token";
probe.resolve(success());
await expect.poll(() => button("Check again").disabled).toBe(false);
expect(restored).not.toHaveBeenCalled();
expect(document.querySelector("openclaw-modal-dialog")).not.toBeNull();
button("Check again").click();
await expect.poll(() => restored.mock.calls.length).toBe(1);
});
it("keeps dismissal quiet across later automatic attachment retries", async () => {
@ -256,21 +241,19 @@ describe("browser sign-in recovery", () => {
});
it("discards an in-flight probe when its document owner stops", async () => {
let finishProbe: (response: Response) => void = () => {};
const probe = createDeferred<Response>();
vi.stubGlobal(
"fetch",
vi.fn(async (_url: string, init?: RequestInit) => {
if (init?.method === "HEAD") {
return new Promise<Response>((resolve) => {
finishProbe = resolve;
});
return probe.promise;
}
throw new TypeError("Failed to fetch");
}),
);
await expect(fetchControlUiResource("/nested/__openclaw__/assistant-media")).rejects.toThrow();
stop();
finishProbe(redirectResponse());
probe.resolve(redirectResponse());
await Promise.resolve();
await Promise.resolve();
expect(document.querySelector("openclaw-modal-dialog")).toBeNull();

View file

@ -104,7 +104,7 @@ export function startBrowserAuthRecovery(
current.every((candidate, index) => candidate === authCandidates[index])
);
};
const probe = (async () => {
pending = (async () => {
try {
// This canonical endpoint never redirects. Manual mode exposes an edge
// redirect without following it or forwarding Gateway credentials to it.
@ -147,8 +147,7 @@ export function startBrowserAuthRecovery(
probeResult = "unavailable";
}
}
})();
pending = probe.finally(() => {
})().finally(() => {
pending = undefined;
dialog?.update();
});
@ -187,14 +186,11 @@ export function startBrowserAuthRecovery(
onFocus();
}
};
serviceWorker?.addEventListener("message", onWorkerMessage);
window.addEventListener("focus", onFocus);
document.addEventListener("visibilitychange", onVisibilityChange);
serviceWorker?.addEventListener("message", onWorkerMessage, { signal: lifetime.signal });
window.addEventListener("focus", onFocus, { signal: lifetime.signal });
document.addEventListener("visibilitychange", onVisibilityChange, { signal: lifetime.signal });
return () => {
lifetime.abort();
stopFailures();
serviceWorker?.removeEventListener("message", onWorkerMessage);
window.removeEventListener("focus", onFocus);
document.removeEventListener("visibilitychange", onVisibilityChange);
};
}

View file

@ -285,6 +285,7 @@ describe("Control UI service worker cache versioning", () => {
listener: (event: { data: unknown; ports: unknown[] }) => void,
) => listeners.set(type, listener),
location: { href: "https://control.example/sw.js?v=old-build" },
registration: { scope: "https://control.example/" },
},
});
listeners.get("message")?.({

View file

@ -2,6 +2,7 @@ import path from "node:path";
import type { Route } from "playwright";
import { expect, it } from "vitest";
import { CONTROL_UI_BOOTSTRAP_CONFIG_PATH } from "../../../src/gateway/control-ui-bootstrap-contract.js";
import { createDeferred } from "../../../test/helpers/promise.js";
import {
controlUiSessionUrl,
createControlUiMockSameOriginGatewayScript,
@ -27,14 +28,8 @@ suite.define(() => {
let metadataRequests = 0;
let outgoingRequests = 0;
let probes = 0;
let releaseMedia!: () => void;
let releaseProbe!: () => void;
const mediaGate = new Promise<void>((resolve) => {
releaseMedia = resolve;
});
const probeGate = new Promise<void>((resolve) => {
releaseProbe = resolve;
});
const mediaGate = createDeferred();
const probeGate = createDeferred();
const image = Buffer.from(
await page.evaluate(() => {
const canvas = document.createElement("canvas");
@ -97,7 +92,7 @@ suite.define(() => {
return;
}
probes += 1;
await probeGate;
await probeGate.promise;
await route.fulfill(
expired
? { status: 302, headers: { location: "https://sign-in.example.test/login" } }
@ -116,7 +111,7 @@ suite.define(() => {
);
await context.route("**/api/chat/media/outgoing/**", async (route) => {
outgoingRequests += 1;
await mediaGate;
await mediaGate.promise;
await route.fulfill(
expired
? { status: 302, headers: { location: "https://sign-in.example.test/login" } }
@ -128,7 +123,7 @@ suite.define(() => {
if (metadata) {
metadataRequests += 1;
}
await mediaGate;
await mediaGate.promise;
if (expired) {
await route.fulfill({
status: 302,
@ -173,13 +168,13 @@ suite.define(() => {
const originalUrl = page.url();
const initialConnects = (await gateway.getRequests("connect")).length;
expect(initialConnects).toBe(1);
releaseMedia();
mediaGate.resolve();
await page.getByRole("button", { name: "Retry", exact: true }).first().waitFor();
await page.screenshot({
animations: "disabled",
path: path.join(artifactDir, "01-image-unavailable.png"),
});
releaseProbe();
probeGate.resolve();
const modal = page
.locator("openclaw-modal-dialog")
.filter({ hasText: "Sign in to continue loading content" });

View file

@ -168,17 +168,13 @@ export class ChatMediaSourceController {
}
handleError(media: HTMLMediaElement): boolean {
if (!this.pendingSource) {
this.sourceFailed = true;
this.playbackSource = "";
this.playbackReadiness = "unavailable";
return false;
if (this.applyPendingSource(media)) {
return true;
}
this.applySource(media, this.pendingSource, this.pendingIdentity, {
currentTime: finiteMediaTime(media.currentTime),
paused: media.paused,
});
return true;
this.sourceFailed = true;
this.playbackSource = "";
this.playbackReadiness = "unavailable";
return false;
}
applyPendingSource(media: HTMLMediaElement): boolean {

View file

@ -6,6 +6,7 @@ import {
isLocalAssistantAttachmentSource,
} from "./chat-message-local-media.ts";
import {
clearChatMediaResourceRefresh,
isChatMediaResourceCurrent,
notifyChatMediaResourceSubscribers,
observeChatMediaResource,
@ -259,7 +260,7 @@ function resetAssistantAttachmentAvailability(
resource.value = undefined;
resource.retainUntil = undefined;
resource.retryAttempted = false;
scheduleAssistantAttachmentRefresh(resource, { status: "checking" });
clearChatMediaResourceRefresh(resource);
}
function createUnavailableAssistantAttachment(

View file

@ -27,6 +27,7 @@ import {
} from "./chat-message-local-media.ts";
import {
cacheManagedImageBlob,
clearChatMediaResourceRefresh,
isChatMediaResourceCurrent,
notifyChatMediaResourceSubscribers,
observeChatMediaResource,
@ -67,7 +68,11 @@ class MessageImageResourceDirective extends AsyncDirective {
private presentationKey = Symbol("image-presentation");
private retained: RetainedInlineImage | { status: "unavailable" } | undefined;
// Resource updates stay in this part; row ResizeObserver owns layout changes.
private readonly requestUpdate = () => this.refreshImage();
private readonly refreshImage = () => {
if (this.isConnected && this.image) {
this.setValue(this.render(this.image, this.options));
}
};
private readonly onSettled = (event: Event, source: string) => {
// A removed IMG may finish after denial; it no longer owns displayed pixels.
const element = event.currentTarget;
@ -119,13 +124,13 @@ class MessageImageResourceDirective extends AsyncDirective {
this.element = undefined;
this.presentationKey = Symbol("image-presentation");
}
releaseChatMediaResourceSubscriber(this.requestUpdate);
releaseChatMediaResourceSubscriber(this.refreshImage);
}
this.image = image;
this.options = options;
if (!this.isConnected) {
this.releaseRetainedImage();
releaseChatMediaResourceSubscriber(this.requestUpdate);
releaseChatMediaResourceSubscriber(this.refreshImage);
return noChange;
}
const onRequestUpdate = options?.onRequestUpdate;
@ -134,12 +139,12 @@ class MessageImageResourceDirective extends AsyncDirective {
// callback changes without discarding its loaded resource.
if (onRequestUpdate) {
this.pendingPreview = undefined;
observeChatMediaResourceSubscriber(onRequestUpdate, this.requestUpdate);
observeChatMediaResourceSubscriber(onRequestUpdate, this.refreshImage);
} else {
releaseChatMediaResourceSubscriber(this.requestUpdate);
releaseChatMediaResourceSubscriber(this.refreshImage);
}
const subscriptionOptions = onRequestUpdate
? { ...options, onRequestUpdate: this.requestUpdate }
? { ...options, onRequestUpdate: this.refreshImage }
: options;
const availability = resolveAssistantAttachmentAvailability(image.url, subscriptionOptions);
const decodeFailed = this.retained?.status === "unavailable";
@ -330,7 +335,7 @@ class MessageImageResourceDirective extends AsyncDirective {
resolveManagedOutgoingImageResource(
image.url,
this.options?.onRequestUpdate
? { ...this.options, onRequestUpdate: this.requestUpdate }
? { ...this.options, onRequestUpdate: this.refreshImage }
: this.options,
image.artifactId,
"thumbnail",
@ -361,12 +366,6 @@ class MessageImageResourceDirective extends AsyncDirective {
this.refreshImage();
}
private refreshImage() {
if (this.isConnected && this.image) {
this.setValue(this.render(this.image, this.options));
}
}
private present(value: unknown) {
return html`${keyed(this.presentationKey, value)}`;
}
@ -376,7 +375,7 @@ class MessageImageResourceDirective extends AsyncDirective {
this.element = undefined;
this.pendingPreview = undefined;
this.presentationKey = Symbol("image-presentation");
releaseChatMediaResourceSubscriber(this.requestUpdate);
releaseChatMediaResourceSubscriber(this.refreshImage);
}
protected override reconnected() {
@ -547,9 +546,7 @@ function resolveManagedOutgoingImageResource(
resource.value = undefined;
resource.retryAttempted = false;
resource.unavailableAt = undefined;
scheduleChatMediaResourceRefresh(resource, undefined, () =>
notifyChatMediaResourceSubscribers(resource),
);
clearChatMediaResourceRefresh(resource);
notifyChatMediaResourceSubscribers(resource);
});
}
@ -640,7 +637,7 @@ async function fetchManagedOutgoingImageBlob(
opts: ImageRenderOptions | undefined,
artifactId: string | undefined,
variant: ManagedImageVariant,
controller = new AbortController(),
controller: AbortController,
): Promise<Blob | null> {
const requesterSessionKey = resolveManagedOutgoingMediaSessionKey(source);
const artifactDownload =

View file

@ -996,7 +996,12 @@ describe("chat media resource lifecycle", () => {
const { blobUrl } = installManagedImageUrls();
const resolveArtifactDownload = vi.fn(async () => ({ url: ticketedUrl }));
const fetchMock = vi
.fn()
.fn<
(
url: string,
init: RequestInit,
) => Promise<{ ok: false } | ReturnType<typeof imageResponse>>
>()
.mockResolvedValueOnce({ ok: false })
.mockResolvedValueOnce(imageResponse());
vi.stubGlobal("fetch", fetchMock);
@ -1013,7 +1018,7 @@ describe("chat media resource lifecycle", () => {
expect(resolveArtifactDownload).toHaveBeenCalledTimes(2);
expect(fetchMock).toHaveBeenCalledTimes(2);
for (const [requestUrl, init] of fetchMock.mock.calls as Array<[string, RequestInit]>) {
for (const [requestUrl, init] of fetchMock.mock.calls) {
expect(requestUrl).toBe(ticketedUrl.replace(/\/full(?=\?)/u, "/thumbnail"));
const headers = new Headers(init.headers);
expect(headers.get("Authorization")).toBeNull();

View file

@ -144,10 +144,7 @@ function detachChatMediaResourceSubscriber(
}
resource.releaseAuthRecovery?.();
resource.releaseAuthRecovery = undefined;
if (resource.refresh) {
clearTimeout(resource.refresh.timer);
resource.refresh = undefined;
}
clearChatMediaResourceRefresh(resource);
const resourceKey = chatMediaResourceKey(resource.kind, resource.cacheKey);
if (chatMediaResources.get(resourceKey) === resource) {
chatMediaResources.delete(resourceKey);
@ -184,9 +181,7 @@ export function observeChatMediaResource<Value>(
) {
chatMediaResources.delete(resourceKey);
resource.abortController?.abort();
if (resource.refresh) {
clearTimeout(resource.refresh.timer);
}
clearChatMediaResourceRefresh(resource);
resource = undefined;
}
if (!resource) {
@ -204,7 +199,7 @@ export function observeChatMediaResource<Value>(
refresh: undefined,
retainUntil: undefined,
};
chatMediaResources.set(resourceKey, resource as ChatMediaResource<unknown>);
chatMediaResources.set(resourceKey, resource);
}
const newObservation = !subscriber || !resource.subscribers.has(subscriber);
if (subscriber) {
@ -216,7 +211,7 @@ export function observeChatMediaResource<Value>(
if (previous && previous !== resource) {
detachChatMediaResourceSubscriber(previous, subscriber);
}
subscriptions.set(subscriptionKey, resource as ChatMediaResource<unknown>);
subscriptions.set(subscriptionKey, resource);
}
if (cacheScope !== undefined && newObservation) {
// Policy changes can replace the directive. Let active readers finish, but
@ -266,6 +261,13 @@ export function notifyChatMediaResourceSubscribers<Value>(resource: ChatMediaRes
}
}
export function clearChatMediaResourceRefresh(resource: ChatMediaResource<unknown>) {
if (resource.refresh) {
clearTimeout(resource.refresh.timer);
resource.refresh = undefined;
}
}
export function scheduleChatMediaResourceRefresh<Value>(
resource: ChatMediaResource<Value>,
refreshAt: number | undefined,
@ -274,10 +276,7 @@ export function scheduleChatMediaResourceRefresh<Value>(
if (resource.refresh?.at === refreshAt) {
return;
}
if (resource.refresh) {
clearTimeout(resource.refresh.timer);
resource.refresh = undefined;
}
clearChatMediaResourceRefresh(resource);
if (refreshAt === undefined || resource.subscribers.size === 0) {
return;
}