mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-06 17:57:43 +02:00
[eric] passkey-not-supported dialog: intercept WebAuthn in agent browsers and surface a modal instead of letting the page hang on the passkey verifying spinner,
shim injected from main via contents.executeJavaScript (bypasses Trusted Types CSP), webview preload force-attached in will-attach-webview
This commit is contained in:
@@ -239,11 +239,16 @@ async def sync():
|
||||
No-op when not in openswarm-pro mode. Best-effort: network failures are
|
||||
swallowed — the caller still gets a 200 with whatever local state we
|
||||
already had."""
|
||||
# Lazy-import the PostHog helper so subscription/router doesn't pay the
|
||||
# cost when analytics are disabled.
|
||||
from backend.apps.analytics.collector import record as _record
|
||||
|
||||
settings_obj = load_settings()
|
||||
bearer = getattr(settings_obj, "openswarm_bearer_token", None)
|
||||
mode = getattr(settings_obj, "connection_mode", "own_key")
|
||||
|
||||
if mode != "openswarm-pro" or not bearer:
|
||||
_record("subscription.sync_ran", {"reason": "no_bearer"})
|
||||
return {"ok": True, "synced": False, "connection_mode": mode}
|
||||
|
||||
try:
|
||||
@@ -254,6 +259,7 @@ async def sync():
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
logger.debug("subscription/sync live fetch failed: %s", e)
|
||||
_record("subscription.sync_ran", {"reason": "network"})
|
||||
return {"ok": True, "synced": False, "reason": "network"}
|
||||
|
||||
# Same 401/402 handling as /status: if Stripe-side reconciliation proves
|
||||
@@ -261,15 +267,18 @@ async def sync():
|
||||
# reverts to own_key instead of hammering a useless token.
|
||||
if r.status_code in (401, 402):
|
||||
_clear_subscription(settings_obj)
|
||||
reason = "revoked" if r.status_code == 401 else "expired"
|
||||
_record("subscription.sync_ran", {"reason": reason})
|
||||
return {
|
||||
"ok": True,
|
||||
"synced": False,
|
||||
"connected": False,
|
||||
"reason": "revoked" if r.status_code == 401 else "expired",
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
if r.status_code != 200:
|
||||
logger.debug("subscription/sync got %s from cloud: %s", r.status_code, r.text[:200])
|
||||
_record("subscription.sync_ran", {"reason": "upstream", "status_code": r.status_code})
|
||||
return {"ok": True, "synced": False, "reason": "upstream"}
|
||||
|
||||
data = r.json()
|
||||
@@ -287,6 +296,11 @@ async def sync():
|
||||
)
|
||||
_write_settings(settings_obj)
|
||||
_sync_subscription_identity(settings_obj)
|
||||
_record("subscription.sync_ran", {
|
||||
"reason": "ok",
|
||||
"synced": bool(data.get("synced")),
|
||||
"plan": cloud_plan,
|
||||
})
|
||||
return {
|
||||
"ok": True,
|
||||
"synced": bool(data.get("synced")),
|
||||
|
||||
+68
-2
@@ -283,9 +283,21 @@ function createWindow() {
|
||||
mainWindow.loadFile(frontendPath);
|
||||
}
|
||||
|
||||
mainWindow.webContents.on('will-attach-webview', (_event, webPreferences, _params) => {
|
||||
mainWindow.webContents.on('will-attach-webview', (_event, webPreferences, params) => {
|
||||
webPreferences.plugins = true;
|
||||
webPreferences.enableBlinkFeatures = 'EncryptedMedia';
|
||||
// Force our webview preload to attach for every <webview>, unconditionally.
|
||||
// The alternative (reading window.openswarm.getWebviewPreloadPath() in
|
||||
// BrowserCard's React code at module-eval time) raced against the
|
||||
// preload's async contextBridge exposure — the resulting attribute on
|
||||
// the <webview> element ended up empty, so no preload ran and our
|
||||
// passkey shim never loaded. Setting webPreferences.preload here runs
|
||||
// on every attach and can't be out-raced. Absolute path (not file://)
|
||||
// is what webPreferences expects.
|
||||
webPreferences.preload = path.join(__dirname, 'webview-preload.js');
|
||||
try {
|
||||
console.log('[openswarm:attach-webview] forced preload=', webPreferences.preload, 'src=', params.src);
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
mainWindow.webContents.on('will-navigate', (event, url) => {
|
||||
@@ -528,7 +540,9 @@ app.on('web-contents-created', (_event, contents) => {
|
||||
contents.on('console-message', (_e, level, message, line, sourceId) => {
|
||||
if (message.includes('widevine') || message.includes('drm') ||
|
||||
message.includes('license') || message.includes('MediaKeySession') ||
|
||||
message.includes('EME') || message.includes('[drm-diag]') || level >= 2) {
|
||||
message.includes('EME') || message.includes('[drm-diag]') ||
|
||||
message.includes('openswarm') ||
|
||||
level >= 2) {
|
||||
const tag = ['LOG', 'INFO', 'WARN', 'ERROR'][level] || 'LOG';
|
||||
const src = sourceId ? sourceId.split('/').pop() : '';
|
||||
console.log(`[webview:${tag}] ${message}${src ? ` (${src}:${line})` : ''}`);
|
||||
@@ -564,7 +578,59 @@ app.on('web-contents-created', (_event, contents) => {
|
||||
cdpQueueByWcId.delete(contents.id);
|
||||
});
|
||||
|
||||
// WebAuthn/passkey shim. Injected on every dom-ready in the main world
|
||||
// via executeJavaScript (which uses V8's direct evaluation path and
|
||||
// bypasses Trusted Types CSP — inline <script> injection from the
|
||||
// webview preload was being blocked on accounts.google.com because of
|
||||
// `require-trusted-types-for 'script'`). The shim overrides
|
||||
// navigator.credentials so passkey calls reject cleanly and post a
|
||||
// tagged message back; webview-preload.js listens and forwards to the
|
||||
// embedder, which surfaces the "Passkeys aren't supported" dialog.
|
||||
contents.on('dom-ready', () => {
|
||||
contents.executeJavaScript(`
|
||||
(function() {
|
||||
if (window.__openswarm_passkey_shim__) return;
|
||||
window.__openswarm_passkey_shim__ = true;
|
||||
try {
|
||||
console.warn('[openswarm:shim] main-world shim installing at', location.href);
|
||||
var notify = function(kind) {
|
||||
try { console.warn('[openswarm:shim] passkey intercepted:', kind); } catch (_) {}
|
||||
try { window.postMessage({ __openswarm__: '__openswarm_passkey__' }, '*'); } catch (_) {}
|
||||
};
|
||||
var rejected = function() {
|
||||
return Promise.reject(new DOMException(
|
||||
'OpenSwarm does not support passkeys. Please use another sign-in method.',
|
||||
'NotAllowedError'
|
||||
));
|
||||
};
|
||||
if (navigator.credentials) {
|
||||
var origGet = navigator.credentials.get && navigator.credentials.get.bind(navigator.credentials);
|
||||
navigator.credentials.get = function(options) {
|
||||
if (options && options.publicKey) {
|
||||
if (options.mediation !== 'conditional') notify('get:' + (options.mediation || 'default'));
|
||||
return rejected();
|
||||
}
|
||||
return origGet ? origGet(options) : Promise.reject(new DOMException('Not supported', 'NotSupportedError'));
|
||||
};
|
||||
var origCreate = navigator.credentials.create && navigator.credentials.create.bind(navigator.credentials);
|
||||
navigator.credentials.create = function(options) {
|
||||
if (options && options.publicKey) { notify('create'); return rejected(); }
|
||||
return origCreate ? origCreate(options) : Promise.reject(new DOMException('Not supported', 'NotSupportedError'));
|
||||
};
|
||||
console.warn('[openswarm:shim] navigator.credentials patched');
|
||||
}
|
||||
if (window.PublicKeyCredential) {
|
||||
window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable = function() { return Promise.resolve(false); };
|
||||
if (window.PublicKeyCredential.isConditionalMediationAvailable) {
|
||||
window.PublicKeyCredential.isConditionalMediationAvailable = function() { return Promise.resolve(false); };
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
try { console.warn('[openswarm:shim] error:', e && e.message); } catch (_) {}
|
||||
}
|
||||
})();
|
||||
`).catch(() => {});
|
||||
|
||||
const url = contents.getURL();
|
||||
if (url.includes('spotify')) {
|
||||
contents.executeJavaScript(`
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
|
||||
'use strict';
|
||||
|
||||
// Diagnostic marker so we can confirm the preload actually attached to
|
||||
// this webview. Surfaces via main.js's console-message listener.
|
||||
try { console.warn('[openswarm:webview-preload] loaded for', window.location.href); } catch (_) {}
|
||||
|
||||
// Hide webdriver flag
|
||||
Object.defineProperty(navigator, 'webdriver', {
|
||||
get: () => false,
|
||||
@@ -85,3 +89,50 @@ try {
|
||||
// Fix console.debug detection (some sites use it as a breakpoint detector)
|
||||
const noop = () => {};
|
||||
if (!window.console.debug) window.console.debug = noop;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Passkey / WebAuthn handling
|
||||
//
|
||||
// Electron webviews can't trigger the OS platform authenticator (Touch ID,
|
||||
// Windows Hello) — see electron/electron#15404, #24573. Sites that offer
|
||||
// "Sign in with passkey" either fail silently or loop (#41472 on LinkedIn).
|
||||
//
|
||||
// With contextIsolation on (the Electron default), any patches we make to
|
||||
// navigator.credentials from this preload only apply in the ISOLATED world;
|
||||
// the page's own JS runs in the MAIN world and sees the original API. We
|
||||
// have to inject the shim via webFrame.executeJavaScript so it lands in
|
||||
// the page's JS context, then bridge the event back out with a DOM
|
||||
// CustomEvent that this isolated-world preload listens for and relays via
|
||||
// ipcRenderer.sendToHost to the embedding <webview> element.
|
||||
//
|
||||
// Two-pronged shim (both evaluated in the main world):
|
||||
// 1. Probe APIs (isUserVerifyingPlatformAuthenticatorAvailable,
|
||||
// isConditionalMediationAvailable) return false so sites that check
|
||||
// before rendering a passkey button fall back to passwords quietly.
|
||||
// 2. credentials.get / credentials.create with publicKey options reject
|
||||
// with a clean NotAllowedError AND dispatch the passkey event so the
|
||||
// embedder can surface a dialog. Conditional mediation (silent
|
||||
// autofill) is intercepted but doesn't fire the dialog — that's
|
||||
// not a user click.
|
||||
// ---------------------------------------------------------------------------
|
||||
try {
|
||||
const { ipcRenderer } = require('electron');
|
||||
|
||||
// The actual WebAuthn shim is injected by the MAIN process via
|
||||
// contents.executeJavaScript on each 'dom-ready' (see electron/main.js).
|
||||
// That path runs in the page's main world and bypasses Trusted Types
|
||||
// CSP enforcement, which blocks our previous inline-<script> approach
|
||||
// on sites like accounts.google.com.
|
||||
//
|
||||
// Our only job here is to act as the postMessage→IPC bridge: the main-
|
||||
// world shim posts a tagged message, we relay it via sendToHost to the
|
||||
// embedding <webview> element, which shows the "passkeys not supported"
|
||||
// dialog.
|
||||
window.addEventListener('message', (event) => {
|
||||
if (event.source !== window) return;
|
||||
if (event.data && event.data.__openswarm__ === '__openswarm_passkey__') {
|
||||
console.warn('[openswarm:webview-preload] passkey bridge → sendToHost');
|
||||
try { ipcRenderer.sendToHost('passkey-detected', window.location.href); } catch (_) {}
|
||||
}
|
||||
});
|
||||
} catch (_) {}
|
||||
|
||||
@@ -346,6 +346,11 @@ const OnboardingModal: React.FC = () => {
|
||||
// system browser. The post-payment openswarm://auth deep link will
|
||||
// dismiss this modal automatically via useDeepLink → fetchSettings.
|
||||
if (providerId === 'openswarm-pro') {
|
||||
trackEvent('subscription.subscribe_clicked', {
|
||||
source: 'onboarding',
|
||||
plan: 'pro',
|
||||
billing_interval: 'monthly',
|
||||
});
|
||||
try {
|
||||
const r = await fetch('https://api.openswarm.com/api/stripe/checkout', {
|
||||
method: 'POST',
|
||||
@@ -354,6 +359,12 @@ const OnboardingModal: React.FC = () => {
|
||||
});
|
||||
if (r.ok) {
|
||||
const { url } = await r.json();
|
||||
if (url) {
|
||||
trackEvent('subscription.checkout_opened', {
|
||||
source: 'onboarding',
|
||||
plan: 'pro',
|
||||
});
|
||||
}
|
||||
const api = (window as any).openswarm;
|
||||
if (url && api?.openExternal) api.openExternal(url);
|
||||
else if (url) window.open(url, '_blank');
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { trackEvent } from '@/shared/analytics';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
@@ -664,6 +665,15 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, o
|
||||
// uses ("API Error: NNN …") and the raw JSON body.
|
||||
const openswarmError = !isUser ? parseOpenSwarmError(rawText) : null;
|
||||
|
||||
// Fire subscription.rate_limit_hit exactly once per rate-limit error
|
||||
// card mount. Dependency on (message.id, kind) ensures we don't re-fire
|
||||
// on re-renders or content edits.
|
||||
React.useEffect(() => {
|
||||
if (openswarmError?.kind === 'cap') {
|
||||
trackEvent('subscription.rate_limit_hit', { message_id: message.id });
|
||||
}
|
||||
}, [message.id, openswarmError?.kind]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (editing) setEditText(rawText);
|
||||
}, [editing, rawText]);
|
||||
|
||||
@@ -7,6 +7,10 @@ import InputBase from '@mui/material/InputBase';
|
||||
import LinearProgress from '@mui/material/LinearProgress';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Button from '@mui/material/Button';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import Fade from '@mui/material/Fade';
|
||||
import LanguageIcon from '@mui/icons-material/Language';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
@@ -75,10 +79,20 @@ const chromeUserAgent = navigator.userAgent
|
||||
.replace(/\s*Electron\/\S+/, '')
|
||||
.replace(/\s*OpenSwarm\/\S+/, '');
|
||||
|
||||
// Read from the sync exposure first (set at preload boot, always present
|
||||
// by the time modules evaluate). Fall back to the async `openswarm` API
|
||||
// for backward compatibility. If you see `<openswarm:webview-preload>`
|
||||
// logs in the terminal, this attached; if you don't, it didn't.
|
||||
const webviewPreloadPath: string | undefined = isElectron
|
||||
? (window as any).openswarm?.getWebviewPreloadPath?.()
|
||||
? ((window as any).__OPENSWARM_WEBVIEW_PRELOAD__
|
||||
|| (window as any).openswarm?.getWebviewPreloadPath?.())
|
||||
: undefined;
|
||||
|
||||
if (isElectron) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[openswarm:card-module] webviewPreloadPath =', webviewPreloadPath);
|
||||
}
|
||||
|
||||
type WebviewElement = BrowserWebview;
|
||||
|
||||
interface TabLocalState {
|
||||
@@ -140,6 +154,11 @@ const BrowserCard: React.FC<Props> = ({
|
||||
const lastAction = activity.lastAction;
|
||||
|
||||
const [tabLocalStates, setTabLocalStates] = useState<Record<string, TabLocalState>>({});
|
||||
// Electron webviews can't trigger the OS platform authenticator (see
|
||||
// webview-preload.js for the WebAuthn shim). When the preload catches a
|
||||
// passkey call it sends `ipc-message` "passkey-detected"; we surface a
|
||||
// modal so the user knows why the sign-in didn't work.
|
||||
const [passkeyDialogOpen, setPasskeyDialogOpen] = useState(false);
|
||||
const updateTabLocal = useCallback((tabId: string, update: Partial<TabLocalState>) => {
|
||||
setTabLocalStates((prev) => {
|
||||
const existing = prev[tabId] ?? { loading: false, canGoBack: false, canGoForward: false };
|
||||
@@ -200,6 +219,14 @@ const BrowserCard: React.FC<Props> = ({
|
||||
});
|
||||
};
|
||||
|
||||
const onIpcMessage = (e: any) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[openswarm:card] webview ipc-message:', e?.channel, e?.args);
|
||||
if (e?.channel === 'passkey-detected') {
|
||||
setPasskeyDialogOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const onTitleUpdate = () => {
|
||||
dispatch(updateBrowserTabTitle({ browserId, tabId, title: wv.getTitle() }));
|
||||
};
|
||||
@@ -224,6 +251,7 @@ const BrowserCard: React.FC<Props> = ({
|
||||
wv.addEventListener('did-start-loading', onLoadStart);
|
||||
wv.addEventListener('did-stop-loading', onLoadStop);
|
||||
wv.addEventListener('page-favicon-updated', onFaviconUpdate);
|
||||
wv.addEventListener('ipc-message', onIpcMessage as any);
|
||||
|
||||
cleanups.push(() => {
|
||||
unregisterWebview(browserId, tabId);
|
||||
@@ -233,6 +261,7 @@ const BrowserCard: React.FC<Props> = ({
|
||||
wv.removeEventListener('did-start-loading', onLoadStart);
|
||||
wv.removeEventListener('did-stop-loading', onLoadStop);
|
||||
wv.removeEventListener('page-favicon-updated', onFaviconUpdate);
|
||||
wv.removeEventListener('ipc-message', onIpcMessage as any);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1034,7 +1063,47 @@ const BrowserCard: React.FC<Props> = ({
|
||||
}}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
) : null}
|
||||
<Dialog
|
||||
open={passkeyDialogOpen}
|
||||
onClose={() => setPasskeyDialogOpen(false)}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
bgcolor: c.bg.surface,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
borderRadius: `${c.radius.lg}px`,
|
||||
maxWidth: 420,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ fontSize: '1rem', fontWeight: 700, color: c.text.primary, pb: 1 }}>
|
||||
Passkeys aren't supported
|
||||
</DialogTitle>
|
||||
<DialogContent sx={{ pb: 1 }}>
|
||||
<Typography sx={{ fontSize: '0.85rem', color: c.text.secondary, lineHeight: 1.5 }}>
|
||||
Sorry — OpenSwarm doesn't support passkeys. Please sign in with a password or another method.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button
|
||||
onClick={() => setPasskeyDialogOpen(false)}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontSize: '0.82rem',
|
||||
fontWeight: 600,
|
||||
bgcolor: c.accent.primary,
|
||||
color: '#fff',
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
px: 2.25,
|
||||
py: 0.6,
|
||||
'&:hover': { bgcolor: c.accent.hover || c.accent.primary },
|
||||
}}
|
||||
>
|
||||
OK
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
{!isElectron && (
|
||||
<Box sx={{ width: '100%', height: '100%', position: 'relative' }}>
|
||||
<iframe
|
||||
src={activeUrl}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import { trackEvent } from '@/shared/analytics';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import TextField from '@mui/material/TextField';
|
||||
@@ -158,6 +159,10 @@ const OpenSwarmProCard: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const [status, setStatus] = useState<OpenSwarmProStatus | null>(null);
|
||||
const [busy, setBusy] = useState<'manage' | 'disconnect' | null>(null);
|
||||
// Track which usage thresholds we've already fired this session so the
|
||||
// event doesn't spam PostHog every 30s while the counter hovers past
|
||||
// the threshold. Reset implicitly on page unmount (settings close).
|
||||
const firedUsageThresholds = useRef<Set<number>>(new Set());
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
@@ -175,6 +180,12 @@ const OpenSwarmProCard: React.FC = () => {
|
||||
}, [refresh]);
|
||||
|
||||
const handleSubscribe = async () => {
|
||||
trackEvent('subscription.subscribe_clicked', {
|
||||
source: 'settings',
|
||||
plan: 'pro',
|
||||
billing_interval: 'monthly',
|
||||
was_subscribed: !!status?.last_plan,
|
||||
});
|
||||
try {
|
||||
const r = await fetch('https://api.openswarm.com/api/stripe/checkout', {
|
||||
method: 'POST',
|
||||
@@ -183,6 +194,12 @@ const OpenSwarmProCard: React.FC = () => {
|
||||
});
|
||||
if (r.ok) {
|
||||
const { url } = await r.json();
|
||||
if (url) {
|
||||
trackEvent('subscription.checkout_opened', {
|
||||
source: 'settings',
|
||||
plan: 'pro',
|
||||
});
|
||||
}
|
||||
const api = (window as any).openswarm;
|
||||
if (url && api?.openExternal) api.openExternal(url);
|
||||
else if (url) window.open(url, '_blank');
|
||||
@@ -193,6 +210,10 @@ const OpenSwarmProCard: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleManage = async () => {
|
||||
trackEvent('subscription.manage_clicked', {
|
||||
plan: status?.plan ?? null,
|
||||
status: status?.status ?? null,
|
||||
});
|
||||
setBusy('manage');
|
||||
try {
|
||||
const r = await fetch(`${API_BASE}/subscription/portal`, { method: 'POST' });
|
||||
@@ -217,6 +238,25 @@ const OpenSwarmProCard: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Fire subscription.usage_warning exactly once per threshold per session
|
||||
// when utilization crosses 80% / 90%. Placed before the early return so
|
||||
// the hook chain stays stable.
|
||||
useEffect(() => {
|
||||
if (!status?.connected) return;
|
||||
const rawPct = status.usage?.utilization ?? 0;
|
||||
const current = Math.max(0, Math.min(100, Math.round(rawPct)));
|
||||
for (const threshold of [80, 90] as const) {
|
||||
if (current >= threshold && !firedUsageThresholds.current.has(threshold)) {
|
||||
firedUsageThresholds.current.add(threshold);
|
||||
trackEvent('subscription.usage_warning', {
|
||||
plan: status.plan ?? null,
|
||||
utilization: current,
|
||||
threshold,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
// Loading state — don't flash a CTA that disappears on first fetch.
|
||||
if (!status) return null;
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user