diff --git a/apps/frontend/app/data/page.tsx b/apps/frontend/app/data/page.tsx index d4fedb0..1d5878a 100644 --- a/apps/frontend/app/data/page.tsx +++ b/apps/frontend/app/data/page.tsx @@ -76,9 +76,10 @@ function Row({ export default function DataPage() { const router = useRouter(); - const { user, token, refreshProfile } = useAuth(); + const { user, token, applyUserPatch } = useAuth(); const { isReady, isPinLocked } = useRequireAuth(); const { showToast } = useToast(); + const userId = user?.id; const [displayName, setDisplayName] = useState(''); const [email, setEmail] = useState(''); const [backupEmail, setBackupEmail] = useState(''); @@ -98,15 +99,15 @@ export default function DataPage() { const [cancellingDeletion, setCancellingDeletion] = useState(false); const loadDocuments = useCallback(async () => { - if (!user || !token || isPinLocked) return; + if (!userId || !token || isPinLocked) return; try { - const response = await apiFetch<{ documents?: UserDocument[] }>(`/documents/users/${user.id}`, {}, token); + const response = await apiFetch<{ documents?: UserDocument[] }>(`/documents/users/${userId}`, {}, token); setDocuments(response.documents ?? []); } catch (error) { const message = getApiErrorMessage(error, 'Не удалось загрузить документы'); if (message) showToast(message); } - }, [isPinLocked, showToast, token, user]); + }, [isPinLocked, showToast, token, userId]); useEffect(() => { if (!user) return; @@ -124,31 +125,31 @@ export default function DataPage() { }, [user]); useEffect(() => { - if (!isReady || !user || !token || isPinLocked) return; - void apiFetch(`/profile/users/${user.id}`, {}, token) + if (!isReady || !userId || !token || isPinLocked) return; + void apiFetch(`/profile/users/${userId}`, {}, token) .then((profile) => { if (profile.birthDate) setBirthDate(profile.birthDate); }) .catch(() => undefined); - }, [isPinLocked, isReady, token, user]); + }, [isPinLocked, isReady, token, userId]); useEffect(() => { - if (isReady && user && !isPinLocked) void loadDocuments(); - }, [isPinLocked, isReady, loadDocuments, user]); + if (isReady && userId && !isPinLocked) void loadDocuments(); + }, [isPinLocked, isReady, loadDocuments, userId]); const loadDeletionStatus = useCallback(async () => { - if (!user || !token || isPinLocked) return; + if (!userId || !token || isPinLocked) return; try { - const status = await fetchAccountDeletionStatus(user.id, token); + const status = await fetchAccountDeletionStatus(userId, token); setDeletionStatus(status); } catch { setDeletionStatus(null); } - }, [isPinLocked, token, user]); + }, [isPinLocked, token, userId]); useEffect(() => { - if (isReady && user && !isPinLocked) void loadDeletionStatus(); - }, [isPinLocked, isReady, loadDeletionStatus, user]); + if (isReady && userId && !isPinLocked) void loadDeletionStatus(); + }, [isPinLocked, isReady, loadDeletionStatus, userId]); const documentsByType = useMemo(() => indexDocumentsByType(documents), [documents]); @@ -186,7 +187,13 @@ export default function DataPage() { }, token); } - await refreshProfile(); + applyUserPatch({ + displayName: trimmedName || user.displayName, + email: contacts.email ?? user.email, + phone: contacts.phone ?? user.phone, + backupEmail: contacts.backupEmail ?? user.backupEmail, + backupPhone: contacts.backupPhone ?? user.backupPhone + }); showToast('Профиль обновлён'); } catch (error) { const message = getApiErrorMessage(error, 'Не удалось обновить профиль'); @@ -244,7 +251,9 @@ export default function DataPage() { verificationIcon={user.verificationIcon} className="h-14 w-14" badgeSize="sm" - onUpdated={refreshProfile} + onUpdated={async () => { + applyUserPatch({ hasAvatar: true }); + }} /> ) : ( diff --git a/apps/frontend/components/family/family-sidebar-panel.tsx b/apps/frontend/components/family/family-sidebar-panel.tsx index b530c61..de6035d 100644 --- a/apps/frontend/components/family/family-sidebar-panel.tsx +++ b/apps/frontend/components/family/family-sidebar-panel.tsx @@ -40,7 +40,7 @@ export function FamilySidebarPanel() { } fetchChatRooms(group.id, token) .then((response) => setRooms(response.rooms ?? [])) - .catch(() => setRooms([])); + .catch(() => undefined); }, [group?.id, token]); const roomByPeerUserId = useMemo(() => { diff --git a/apps/frontend/components/id/auth-provider.tsx b/apps/frontend/components/id/auth-provider.tsx index 39c6ed8..509a53c 100644 --- a/apps/frontend/components/id/auth-provider.tsx +++ b/apps/frontend/components/id/auth-provider.tsx @@ -56,6 +56,7 @@ interface AuthContextValue { verifyTotpLogin: (totpChallengeToken: string, code: string) => Promise; completePin: (sessionId: string, pin: string) => Promise; applyLoginAuth: (auth: AuthTokens) => Promise; + applyUserPatch: (patch: Partial) => void; register: (data: { displayName: string; login: string; password: string }) => Promise; refreshProfile: () => Promise; logout: () => void; @@ -273,6 +274,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { router.push('/auth/login'); }, [clearPinLock, router]); + const applyUserPatch = React.useCallback((patch: Partial) => { + setUser((current) => { + if (!current) return current; + const next = { ...current, ...patch }; + cacheUserProfile(next); + return next; + }); + }, []); + const refreshProfile = React.useCallback(async () => { if (refreshInFlightRef.current) { return refreshInFlightRef.current; @@ -691,6 +701,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { verifyTotpLogin, completePin, applyLoginAuth, + applyUserPatch, register, refreshProfile, logout diff --git a/apps/frontend/hooks/use-primary-family.ts b/apps/frontend/hooks/use-primary-family.ts index 026884c..67c3704 100644 --- a/apps/frontend/hooks/use-primary-family.ts +++ b/apps/frontend/hooks/use-primary-family.ts @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useFamilyOverlay } from '@/components/family/family-overlay-provider'; import { useAuth } from '@/components/id/auth-provider'; import { @@ -15,23 +15,26 @@ import { export function useSelectedFamily(enabled = true) { const { user, token, isPinLocked, isLoading, isContentReady } = useAuth(); const { selectedGroupId, setSelectedGroupId } = useFamilyOverlay(); + const userId = user?.id; const [groups, setGroups] = useState([]); const [group, setGroup] = useState(null); const [presenceMembers, setPresenceMembers] = useState([]); const [loading, setLoading] = useState(true); + const hasLoadedRef = useRef(false); const refresh = useCallback(async () => { const accessToken = getAccessToken() ?? token?.trim() ?? null; - if (!enabled || !user || !accessToken || isPinLocked || isLoading || !isContentReady) { + if (!enabled || !userId || !accessToken || isPinLocked || isLoading || !isContentReady) { setGroups([]); setGroup(null); setPresenceMembers([]); + hasLoadedRef.current = false; setLoading(false); return; } try { - const groupsResponse = await fetchFamilyGroups(user.id, accessToken); + const groupsResponse = await fetchFamilyGroups(userId, accessToken); const list = groupsResponse.groups ?? []; setGroups(list); @@ -39,6 +42,7 @@ export function useSelectedFamily(enabled = true) { setGroup(null); setPresenceMembers([]); if (selectedGroupId) setSelectedGroupId(null); + hasLoadedRef.current = true; return; } @@ -55,19 +59,26 @@ export function useSelectedFamily(enabled = true) { ]); setGroup(groupResponse); setPresenceMembers(presenceResponse.members ?? []); + hasLoadedRef.current = true; } catch { - setGroups([]); - setGroup(null); - setPresenceMembers([]); + // Фоновая ошибка не должна сбрасывать уже показанную семью в сайдбаре. } finally { setLoading(false); } - }, [enabled, isContentReady, isLoading, isPinLocked, selectedGroupId, setSelectedGroupId, token, user]); + }, [enabled, isContentReady, isLoading, isPinLocked, selectedGroupId, setSelectedGroupId, token, userId]); useEffect(() => { - setLoading(true); + if (!enabled || !userId || !token || isPinLocked || isLoading || !isContentReady) { + setGroups([]); + setGroup(null); + setPresenceMembers([]); + hasLoadedRef.current = false; + setLoading(false); + return; + } + setLoading(!hasLoadedRef.current); void refresh(); - }, [refresh]); + }, [enabled, isContentReady, isLoading, isPinLocked, refresh, token, userId]); useEffect(() => { const accessToken = getAccessToken() ?? token?.trim() ?? null; diff --git a/apps/frontend/lib/api.ts b/apps/frontend/lib/api.ts index f4dd580..89fcf44 100644 --- a/apps/frontend/lib/api.ts +++ b/apps/frontend/lib/api.ts @@ -677,7 +677,7 @@ export function buildSystemSettingPayload(setting: Pick { } function gatewayRetryDelay(attempt: number): number { - return 800 + attempt * 700; + return 600 + attempt * 500; } /** Повтор при кратковременном 502/503/504 без сброса всего UI в bootstrap. */ diff --git a/apps/frontend/lib/resilient-media-fetch.ts b/apps/frontend/lib/resilient-media-fetch.ts index acb8408..a7e2adc 100644 --- a/apps/frontend/lib/resilient-media-fetch.ts +++ b/apps/frontend/lib/resilient-media-fetch.ts @@ -1,5 +1,5 @@ const GATEWAY_RETRY_STATUS = new Set([502, 503, 504]); -const MEDIA_FETCH_MAX_ATTEMPTS = 8; +const MEDIA_FETCH_MAX_ATTEMPTS = 3; const MEDIA_SLOW_LOAD_WARN_MS = 8_000; const MEDIA_STREAM_PATH = '/media/stream/'; const SAME_ORIGIN_API_PREFIX = '/idp-api';