From 7f10b18336f31a4744c1a953e2c0f23dd099d546 Mon Sep 17 00:00:00 2001 From: lendry Date: Tue, 30 Jun 2026 10:36:21 +0300 Subject: [PATCH] fix and update --- apps/frontend/components/id/auth-provider.tsx | 24 +++++++++++++++-- .../notifications/notification-bell.tsx | 13 +++++---- .../notifications/realtime-provider.tsx | 27 ++++++++++++++++--- apps/frontend/hooks/use-avatar-url.ts | 8 +++--- apps/frontend/hooks/use-primary-family.ts | 10 +++---- apps/frontend/lib/api.ts | 20 +++++++++----- 6 files changed, 73 insertions(+), 29 deletions(-) diff --git a/apps/frontend/components/id/auth-provider.tsx b/apps/frontend/components/id/auth-provider.tsx index 954c06f..5556734 100644 --- a/apps/frontend/components/id/auth-provider.tsx +++ b/apps/frontend/components/id/auth-provider.tsx @@ -40,6 +40,8 @@ interface AuthContextValue { token: string | null; isLoading: boolean; isApiReady: boolean; + /** false первые ~600 ms после bootstrap — блокирует шторм API сразу после логина */ + isContentReady: boolean; isPinLocked: boolean; hasStoredSession: boolean; login: (login: string, password: string) => Promise; @@ -109,6 +111,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = React.useState(null); const [isLoading, setIsLoading] = React.useState(true); const [isApiReady, setIsApiReady] = React.useState(false); + const [isContentReady, setIsContentReady] = React.useState(false); const [isPinLocked, setIsPinLocked] = React.useState(false); const [hasStoredSession, setHasStoredSession] = React.useState(false); const [lockedSessionId, setLockedSessionId] = React.useState(null); @@ -177,8 +180,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { initialBootstrapDoneRef.current = true; setIsApiReady(true); setIsLoading(false); + setIsContentReady(false); if (auth.pinVerified !== false) { - scheduleFedcmSessionSync(auth.accessToken, 2000); + scheduleFedcmSessionSync(auth.accessToken, 2500); } }, [clearPinLock] @@ -546,6 +550,21 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { Boolean(hasStoredSession || token || user) && (isLoading || !isApiReady); + React.useEffect(() => { + if (isBootstrapping) { + setIsContentReady(false); + return; + } + let cancelled = false; + const timer = window.setTimeout(() => { + if (!cancelled) setIsContentReady(true); + }, 650); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [isBootstrapping]); + return ( - {isBootstrapping ? : children} + {isBootstrapping || !isContentReady ? : children} ); diff --git a/apps/frontend/components/notifications/notification-bell.tsx b/apps/frontend/components/notifications/notification-bell.tsx index a316af9..c638edc 100644 --- a/apps/frontend/components/notifications/notification-bell.tsx +++ b/apps/frontend/components/notifications/notification-bell.tsx @@ -10,7 +10,6 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import { AppNotification, fetchNotifications, - fetchUnreadNotificationCount, getApiErrorMessage, markAllNotificationsRead, markNotificationRead, @@ -27,7 +26,7 @@ function formatTime(value: string) { export function NotificationBell() { const router = useRouter(); - const { token, isPinLocked, isLoading, isApiReady } = useAuth(); + const { token, isPinLocked, isLoading, isContentReady } = useAuth(); const { showToast } = useToast(); const { unreadCount, setUnreadCount, subscribe } = useRealtime(); const [open, setOpen] = useState(false); @@ -36,23 +35,23 @@ export function NotificationBell() { const [respondingId, setRespondingId] = useState(null); const load = useCallback(async () => { - if (!token || isPinLocked || isLoading || !isApiReady) return; + if (!token || isPinLocked || isLoading || !isContentReady) return; setLoading(true); try { - const [list, count] = await Promise.all([fetchNotifications(token), fetchUnreadNotificationCount(token)]); + const list = await fetchNotifications(token); setItems(list.notifications ?? []); - setUnreadCount(count.count ?? 0); } catch (error) { const message = getApiErrorMessage(error, 'Не удалось загрузить уведомления'); if (message) showToast(message); } finally { setLoading(false); } - }, [isApiReady, isLoading, isPinLocked, setUnreadCount, showToast, token]); + }, [isContentReady, isLoading, isPinLocked, showToast, token]); useEffect(() => { + if (!open) return; void load(); - }, [load]); + }, [load, open]); useEffect(() => { return subscribe((event) => { diff --git a/apps/frontend/components/notifications/realtime-provider.tsx b/apps/frontend/components/notifications/realtime-provider.tsx index 2e0c400..7c15f6c 100644 --- a/apps/frontend/components/notifications/realtime-provider.tsx +++ b/apps/frontend/components/notifications/realtime-provider.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { useAuth } from '@/components/id/auth-provider'; -import { ensureApiGatewayReady, getWsUrl, type ChatMessage } from '@/lib/api'; +import { ensureApiGatewayReady, fetchUnreadNotificationCount, getWsUrl, type ChatMessage } from '@/lib/api'; import { dispatchAvatarUpdated, dispatchChatRoomAvatarUpdated } from '@/lib/avatar-events'; export interface RealtimeEvent { @@ -31,7 +31,7 @@ interface RealtimeContextValue { const RealtimeContext = React.createContext(null); export function RealtimeProvider({ children }: { children: React.ReactNode }) { - const { user, token, isPinLocked, isLoading, isApiReady } = useAuth(); + const { user, token, isPinLocked, isLoading, isContentReady } = useAuth(); const [unreadCount, setUnreadCount] = React.useState(0); const [connected, setConnected] = React.useState(false); const listenersRef = React.useRef(new Set()); @@ -48,7 +48,26 @@ export function RealtimeProvider({ children }: { children: React.ReactNode }) { }, []); React.useEffect(() => { - if (!user || !token || isPinLocked || isLoading || !isApiReady) { + if (!user || !token || isPinLocked || isLoading || !isContentReady) { + setUnreadCount(0); + return; + } + let cancelled = false; + const timer = window.setTimeout(() => { + void fetchUnreadNotificationCount(token) + .then((result) => { + if (!cancelled) setUnreadCount(result.count ?? 0); + }) + .catch(() => undefined); + }, 400); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [isContentReady, isLoading, isPinLocked, token, user]); + + React.useEffect(() => { + if (!user || !token || isPinLocked || isLoading || !isContentReady) { socketRef.current?.close(); socketRef.current = null; setConnected(false); @@ -121,7 +140,7 @@ export function RealtimeProvider({ children }: { children: React.ReactNode }) { socketRef.current = null; setConnected(false); }; - }, [emit, isApiReady, isLoading, isPinLocked, token, user]); + }, [emit, isContentReady, isLoading, isPinLocked, token, user]); return ( diff --git a/apps/frontend/hooks/use-avatar-url.ts b/apps/frontend/hooks/use-avatar-url.ts index e1a9582..585d9c8 100644 --- a/apps/frontend/hooks/use-avatar-url.ts +++ b/apps/frontend/hooks/use-avatar-url.ts @@ -12,12 +12,12 @@ interface AvatarAccessResponse { } export function useAvatarUrl(userId: string | undefined, hasAvatar: boolean | undefined, token: string | null) { - const { isPinLocked, isLoading, isApiReady } = useAuth(); + const { isPinLocked, isLoading, isContentReady } = useAuth(); const [accessUrl, setAccessUrl] = useState(null); const [resolvingAccessUrl, setResolvingAccessUrl] = useState(false); const refreshAccessUrl = useCallback(async () => { - if (!userId || !token || !hasAvatar || isPinLocked || isLoading || !isApiReady) { + if (!userId || !token || !hasAvatar || isPinLocked || isLoading || !isContentReady) { setAccessUrl(null); setResolvingAccessUrl(false); return; @@ -31,7 +31,7 @@ export function useAvatarUrl(userId: string | undefined, hasAvatar: boolean | un } finally { setResolvingAccessUrl(false); } - }, [hasAvatar, isApiReady, isLoading, isPinLocked, token, userId]); + }, [hasAvatar, isContentReady, isLoading, isPinLocked, token, userId]); useEffect(() => { void refreshAccessUrl(); @@ -59,7 +59,7 @@ export function useAvatarUrl(userId: string | undefined, hasAvatar: boolean | un isLoading: isLoadingBlob, hasError } = useResilientBlobUrl(accessUrl, token, { - enabled: Boolean(hasAvatar && accessUrl && !isPinLocked && !isLoading && isApiReady), + enabled: Boolean(hasAvatar && accessUrl && !isPinLocked && !isLoading && isContentReady), label: `аватар ${userId ?? ''}`.trim() }); diff --git a/apps/frontend/hooks/use-primary-family.ts b/apps/frontend/hooks/use-primary-family.ts index 88d471c..026884c 100644 --- a/apps/frontend/hooks/use-primary-family.ts +++ b/apps/frontend/hooks/use-primary-family.ts @@ -13,7 +13,7 @@ import { } from '@/lib/api'; export function useSelectedFamily(enabled = true) { - const { user, token, isPinLocked, isLoading, isApiReady } = useAuth(); + const { user, token, isPinLocked, isLoading, isContentReady } = useAuth(); const { selectedGroupId, setSelectedGroupId } = useFamilyOverlay(); const [groups, setGroups] = useState([]); const [group, setGroup] = useState(null); @@ -22,7 +22,7 @@ export function useSelectedFamily(enabled = true) { const refresh = useCallback(async () => { const accessToken = getAccessToken() ?? token?.trim() ?? null; - if (!enabled || !user || !accessToken || isPinLocked || isLoading || !isApiReady) { + if (!enabled || !user || !accessToken || isPinLocked || isLoading || !isContentReady) { setGroups([]); setGroup(null); setPresenceMembers([]); @@ -62,7 +62,7 @@ export function useSelectedFamily(enabled = true) { } finally { setLoading(false); } - }, [enabled, isApiReady, isLoading, isPinLocked, selectedGroupId, setSelectedGroupId, token, user]); + }, [enabled, isContentReady, isLoading, isPinLocked, selectedGroupId, setSelectedGroupId, token, user]); useEffect(() => { setLoading(true); @@ -71,10 +71,10 @@ export function useSelectedFamily(enabled = true) { useEffect(() => { const accessToken = getAccessToken() ?? token?.trim() ?? null; - if (!enabled || !accessToken || isPinLocked || isLoading || !isApiReady || !group) return; + if (!enabled || !accessToken || isPinLocked || isLoading || !isContentReady || !group) return; const timer = window.setInterval(() => void refresh(), 25_000); return () => window.clearInterval(timer); - }, [enabled, group, isApiReady, isLoading, isPinLocked, refresh, token]); + }, [enabled, group, isContentReady, isLoading, isPinLocked, refresh, token]); const presenceByUserId = useMemo( () => new Map(presenceMembers.map((member) => [member.userId, member])), diff --git a/apps/frontend/lib/api.ts b/apps/frontend/lib/api.ts index c9010d7..0d7ca5e 100644 --- a/apps/frontend/lib/api.ts +++ b/apps/frontend/lib/api.ts @@ -677,10 +677,12 @@ export function buildSystemSettingPayload(setting: Pick { @@ -689,6 +691,7 @@ function sleep(ms: number): Promise { let gatewayReadyResolved = false; let gatewayReadyPromise: Promise | null = null; +let gatewayReadyAt = 0; let activeApiRequests = 0; const apiRequestWaiters: Array<() => void> = []; @@ -699,6 +702,7 @@ export function resetApiGatewayWarmup() { export function invalidateApiGatewayReady() { gatewayReadyResolved = false; + gatewayReadyAt = 0; } export function isApiGatewayReady() { @@ -708,6 +712,7 @@ export function isApiGatewayReady() { export function markApiGatewayReady() { if (gatewayReadyResolved) return; gatewayReadyResolved = true; + gatewayReadyAt = Date.now(); if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent(API_READY_EVENT)); } @@ -724,13 +729,14 @@ export function subscribeApiReady(listener: () => void): () => void { } async function acquireApiSlot(): Promise { - if (activeApiRequests < API_MAX_CONCURRENT) { - activeApiRequests += 1; - return; + const inBurst = gatewayReadyAt > 0 && Date.now() - gatewayReadyAt < API_BURST_WINDOW_MS; + const limit = inBurst ? API_BURST_MAX_CONCURRENT : API_MAX_CONCURRENT; + + while (activeApiRequests >= limit) { + await new Promise((resolve) => { + apiRequestWaiters.push(resolve); + }); } - await new Promise((resolve) => { - apiRequestWaiters.push(resolve); - }); activeApiRequests += 1; }