fix and update

This commit is contained in:
lendry
2026-06-30 10:36:21 +03:00
parent a76997986a
commit 7f10b18336
6 changed files with 73 additions and 29 deletions

View File

@@ -40,6 +40,8 @@ interface AuthContextValue {
token: string | null; token: string | null;
isLoading: boolean; isLoading: boolean;
isApiReady: boolean; isApiReady: boolean;
/** false первые ~600 ms после bootstrap — блокирует шторм API сразу после логина */
isContentReady: boolean;
isPinLocked: boolean; isPinLocked: boolean;
hasStoredSession: boolean; hasStoredSession: boolean;
login: (login: string, password: string) => Promise<AuthTokens>; login: (login: string, password: string) => Promise<AuthTokens>;
@@ -109,6 +111,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = React.useState<PublicUser | null>(null); const [user, setUser] = React.useState<PublicUser | null>(null);
const [isLoading, setIsLoading] = React.useState(true); const [isLoading, setIsLoading] = React.useState(true);
const [isApiReady, setIsApiReady] = React.useState(false); const [isApiReady, setIsApiReady] = React.useState(false);
const [isContentReady, setIsContentReady] = React.useState(false);
const [isPinLocked, setIsPinLocked] = React.useState(false); const [isPinLocked, setIsPinLocked] = React.useState(false);
const [hasStoredSession, setHasStoredSession] = React.useState(false); const [hasStoredSession, setHasStoredSession] = React.useState(false);
const [lockedSessionId, setLockedSessionId] = React.useState<string | null>(null); const [lockedSessionId, setLockedSessionId] = React.useState<string | null>(null);
@@ -177,8 +180,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
initialBootstrapDoneRef.current = true; initialBootstrapDoneRef.current = true;
setIsApiReady(true); setIsApiReady(true);
setIsLoading(false); setIsLoading(false);
setIsContentReady(false);
if (auth.pinVerified !== false) { if (auth.pinVerified !== false) {
scheduleFedcmSessionSync(auth.accessToken, 2000); scheduleFedcmSessionSync(auth.accessToken, 2500);
} }
}, },
[clearPinLock] [clearPinLock]
@@ -546,6 +550,21 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
Boolean(hasStoredSession || token || user) && Boolean(hasStoredSession || token || user) &&
(isLoading || !isApiReady); (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 ( return (
<AuthContext.Provider <AuthContext.Provider
value={{ value={{
@@ -553,6 +572,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
token, token,
isLoading, isLoading,
isApiReady, isApiReady,
isContentReady,
isPinLocked, isPinLocked,
hasStoredSession, hasStoredSession,
login, login,
@@ -570,7 +590,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
logout logout
}} }}
> >
{isBootstrapping ? <AppBootstrapScreen /> : children} {isBootstrapping || !isContentReady ? <AppBootstrapScreen /> : children}
<PinLockModal open={isPinLocked} isSubmitting={pinSubmitting} error={pinError} onSubmit={handlePinUnlock} /> <PinLockModal open={isPinLocked} isSubmitting={pinSubmitting} error={pinError} onSubmit={handlePinUnlock} />
</AuthContext.Provider> </AuthContext.Provider>
); );

View File

@@ -10,7 +10,6 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { import {
AppNotification, AppNotification,
fetchNotifications, fetchNotifications,
fetchUnreadNotificationCount,
getApiErrorMessage, getApiErrorMessage,
markAllNotificationsRead, markAllNotificationsRead,
markNotificationRead, markNotificationRead,
@@ -27,7 +26,7 @@ function formatTime(value: string) {
export function NotificationBell() { export function NotificationBell() {
const router = useRouter(); const router = useRouter();
const { token, isPinLocked, isLoading, isApiReady } = useAuth(); const { token, isPinLocked, isLoading, isContentReady } = useAuth();
const { showToast } = useToast(); const { showToast } = useToast();
const { unreadCount, setUnreadCount, subscribe } = useRealtime(); const { unreadCount, setUnreadCount, subscribe } = useRealtime();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -36,23 +35,23 @@ export function NotificationBell() {
const [respondingId, setRespondingId] = useState<string | null>(null); const [respondingId, setRespondingId] = useState<string | null>(null);
const load = useCallback(async () => { const load = useCallback(async () => {
if (!token || isPinLocked || isLoading || !isApiReady) return; if (!token || isPinLocked || isLoading || !isContentReady) return;
setLoading(true); setLoading(true);
try { try {
const [list, count] = await Promise.all([fetchNotifications(token), fetchUnreadNotificationCount(token)]); const list = await fetchNotifications(token);
setItems(list.notifications ?? []); setItems(list.notifications ?? []);
setUnreadCount(count.count ?? 0);
} catch (error) { } catch (error) {
const message = getApiErrorMessage(error, 'Не удалось загрузить уведомления'); const message = getApiErrorMessage(error, 'Не удалось загрузить уведомления');
if (message) showToast(message); if (message) showToast(message);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [isApiReady, isLoading, isPinLocked, setUnreadCount, showToast, token]); }, [isContentReady, isLoading, isPinLocked, showToast, token]);
useEffect(() => { useEffect(() => {
if (!open) return;
void load(); void load();
}, [load]); }, [load, open]);
useEffect(() => { useEffect(() => {
return subscribe((event) => { return subscribe((event) => {

View File

@@ -2,7 +2,7 @@
import * as React from 'react'; import * as React from 'react';
import { useAuth } from '@/components/id/auth-provider'; 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'; import { dispatchAvatarUpdated, dispatchChatRoomAvatarUpdated } from '@/lib/avatar-events';
export interface RealtimeEvent { export interface RealtimeEvent {
@@ -31,7 +31,7 @@ interface RealtimeContextValue {
const RealtimeContext = React.createContext<RealtimeContextValue | null>(null); const RealtimeContext = React.createContext<RealtimeContextValue | null>(null);
export function RealtimeProvider({ children }: { children: React.ReactNode }) { 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 [unreadCount, setUnreadCount] = React.useState(0);
const [connected, setConnected] = React.useState(false); const [connected, setConnected] = React.useState(false);
const listenersRef = React.useRef(new Set<Listener>()); const listenersRef = React.useRef(new Set<Listener>());
@@ -48,7 +48,26 @@ export function RealtimeProvider({ children }: { children: React.ReactNode }) {
}, []); }, []);
React.useEffect(() => { 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?.close();
socketRef.current = null; socketRef.current = null;
setConnected(false); setConnected(false);
@@ -121,7 +140,7 @@ export function RealtimeProvider({ children }: { children: React.ReactNode }) {
socketRef.current = null; socketRef.current = null;
setConnected(false); setConnected(false);
}; };
}, [emit, isApiReady, isLoading, isPinLocked, token, user]); }, [emit, isContentReady, isLoading, isPinLocked, token, user]);
return ( return (
<RealtimeContext.Provider value={{ unreadCount, setUnreadCount, subscribe, connected }}> <RealtimeContext.Provider value={{ unreadCount, setUnreadCount, subscribe, connected }}>

View File

@@ -12,12 +12,12 @@ interface AvatarAccessResponse {
} }
export function useAvatarUrl(userId: string | undefined, hasAvatar: boolean | undefined, token: string | null) { 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<string | null>(null); const [accessUrl, setAccessUrl] = useState<string | null>(null);
const [resolvingAccessUrl, setResolvingAccessUrl] = useState(false); const [resolvingAccessUrl, setResolvingAccessUrl] = useState(false);
const refreshAccessUrl = useCallback(async () => { const refreshAccessUrl = useCallback(async () => {
if (!userId || !token || !hasAvatar || isPinLocked || isLoading || !isApiReady) { if (!userId || !token || !hasAvatar || isPinLocked || isLoading || !isContentReady) {
setAccessUrl(null); setAccessUrl(null);
setResolvingAccessUrl(false); setResolvingAccessUrl(false);
return; return;
@@ -31,7 +31,7 @@ export function useAvatarUrl(userId: string | undefined, hasAvatar: boolean | un
} finally { } finally {
setResolvingAccessUrl(false); setResolvingAccessUrl(false);
} }
}, [hasAvatar, isApiReady, isLoading, isPinLocked, token, userId]); }, [hasAvatar, isContentReady, isLoading, isPinLocked, token, userId]);
useEffect(() => { useEffect(() => {
void refreshAccessUrl(); void refreshAccessUrl();
@@ -59,7 +59,7 @@ export function useAvatarUrl(userId: string | undefined, hasAvatar: boolean | un
isLoading: isLoadingBlob, isLoading: isLoadingBlob,
hasError hasError
} = useResilientBlobUrl(accessUrl, token, { } = useResilientBlobUrl(accessUrl, token, {
enabled: Boolean(hasAvatar && accessUrl && !isPinLocked && !isLoading && isApiReady), enabled: Boolean(hasAvatar && accessUrl && !isPinLocked && !isLoading && isContentReady),
label: `аватар ${userId ?? ''}`.trim() label: `аватар ${userId ?? ''}`.trim()
}); });

View File

@@ -13,7 +13,7 @@ import {
} from '@/lib/api'; } from '@/lib/api';
export function useSelectedFamily(enabled = true) { export function useSelectedFamily(enabled = true) {
const { user, token, isPinLocked, isLoading, isApiReady } = useAuth(); const { user, token, isPinLocked, isLoading, isContentReady } = useAuth();
const { selectedGroupId, setSelectedGroupId } = useFamilyOverlay(); const { selectedGroupId, setSelectedGroupId } = useFamilyOverlay();
const [groups, setGroups] = useState<FamilyGroup[]>([]); const [groups, setGroups] = useState<FamilyGroup[]>([]);
const [group, setGroup] = useState<FamilyGroup | null>(null); const [group, setGroup] = useState<FamilyGroup | null>(null);
@@ -22,7 +22,7 @@ export function useSelectedFamily(enabled = true) {
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
const accessToken = getAccessToken() ?? token?.trim() ?? null; const accessToken = getAccessToken() ?? token?.trim() ?? null;
if (!enabled || !user || !accessToken || isPinLocked || isLoading || !isApiReady) { if (!enabled || !user || !accessToken || isPinLocked || isLoading || !isContentReady) {
setGroups([]); setGroups([]);
setGroup(null); setGroup(null);
setPresenceMembers([]); setPresenceMembers([]);
@@ -62,7 +62,7 @@ export function useSelectedFamily(enabled = true) {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [enabled, isApiReady, isLoading, isPinLocked, selectedGroupId, setSelectedGroupId, token, user]); }, [enabled, isContentReady, isLoading, isPinLocked, selectedGroupId, setSelectedGroupId, token, user]);
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
@@ -71,10 +71,10 @@ export function useSelectedFamily(enabled = true) {
useEffect(() => { useEffect(() => {
const accessToken = getAccessToken() ?? token?.trim() ?? null; 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); const timer = window.setInterval(() => void refresh(), 25_000);
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, [enabled, group, isApiReady, isLoading, isPinLocked, refresh, token]); }, [enabled, group, isContentReady, isLoading, isPinLocked, refresh, token]);
const presenceByUserId = useMemo( const presenceByUserId = useMemo(
() => new Map(presenceMembers.map((member) => [member.userId, member])), () => new Map(presenceMembers.map((member) => [member.userId, member])),

View File

@@ -677,10 +677,12 @@ export function buildSystemSettingPayload(setting: Pick<SystemSetting, 'key' | '
} }
const GATEWAY_RETRY_STATUS = new Set([502, 503, 504]); const GATEWAY_RETRY_STATUS = new Set([502, 503, 504]);
const GATEWAY_RETRY_ATTEMPTS = 4; const GATEWAY_RETRY_ATTEMPTS = 2;
const GATEWAY_PROBE_MAX_ATTEMPTS = 48; const GATEWAY_PROBE_MAX_ATTEMPTS = 48;
const GATEWAY_PROBE_BASE_DELAY_MS = 450; const GATEWAY_PROBE_BASE_DELAY_MS = 450;
const API_MAX_CONCURRENT = 8; const API_MAX_CONCURRENT = 8;
const API_BURST_MAX_CONCURRENT = 3;
const API_BURST_WINDOW_MS = 3000;
const API_READY_EVENT = 'idp-api-ready'; const API_READY_EVENT = 'idp-api-ready';
function sleep(ms: number): Promise<void> { function sleep(ms: number): Promise<void> {
@@ -689,6 +691,7 @@ function sleep(ms: number): Promise<void> {
let gatewayReadyResolved = false; let gatewayReadyResolved = false;
let gatewayReadyPromise: Promise<void> | null = null; let gatewayReadyPromise: Promise<void> | null = null;
let gatewayReadyAt = 0;
let activeApiRequests = 0; let activeApiRequests = 0;
const apiRequestWaiters: Array<() => void> = []; const apiRequestWaiters: Array<() => void> = [];
@@ -699,6 +702,7 @@ export function resetApiGatewayWarmup() {
export function invalidateApiGatewayReady() { export function invalidateApiGatewayReady() {
gatewayReadyResolved = false; gatewayReadyResolved = false;
gatewayReadyAt = 0;
} }
export function isApiGatewayReady() { export function isApiGatewayReady() {
@@ -708,6 +712,7 @@ export function isApiGatewayReady() {
export function markApiGatewayReady() { export function markApiGatewayReady() {
if (gatewayReadyResolved) return; if (gatewayReadyResolved) return;
gatewayReadyResolved = true; gatewayReadyResolved = true;
gatewayReadyAt = Date.now();
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent(API_READY_EVENT)); window.dispatchEvent(new CustomEvent(API_READY_EVENT));
} }
@@ -724,13 +729,14 @@ export function subscribeApiReady(listener: () => void): () => void {
} }
async function acquireApiSlot(): Promise<void> { async function acquireApiSlot(): Promise<void> {
if (activeApiRequests < API_MAX_CONCURRENT) { const inBurst = gatewayReadyAt > 0 && Date.now() - gatewayReadyAt < API_BURST_WINDOW_MS;
activeApiRequests += 1; const limit = inBurst ? API_BURST_MAX_CONCURRENT : API_MAX_CONCURRENT;
return;
while (activeApiRequests >= limit) {
await new Promise<void>((resolve) => {
apiRequestWaiters.push(resolve);
});
} }
await new Promise<void>((resolve) => {
apiRequestWaiters.push(resolve);
});
activeApiRequests += 1; activeApiRequests += 1;
} }