fix and update

This commit is contained in:
lendry
2026-06-29 22:51:25 +03:00
parent 885b07d76b
commit 57cb58347b
30 changed files with 799 additions and 187 deletions

View File

@@ -268,9 +268,7 @@ export default function LoginPage() {
try {
const deviceName = navigator.userAgent.includes('Windows') ? 'Windows Browser' : 'Browser';
const session = await createQrLoginSession(deviceName);
const session = await createQrLoginSession();
setQrSessionId(session.sessionId);

View File

@@ -9,13 +9,13 @@ import { useToast } from '@/components/id/toast-provider';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useRequireAuth } from '@/hooks/use-require-auth';
import { apiFetch, FamilyGroup, fetchFamilyGroups, getApiErrorMessage } from '@/lib/api';
import { apiFetch, FamilyGroup, fetchFamilyGroups, getAccessToken, getApiErrorMessage } from '@/lib/api';
import { defaultFamilyGroupName } from '@/lib/family-defaults';
export default function FamilyPage() {
const router = useRouter();
const { user, token } = useAuth();
const { isReady, isPinLocked } = useRequireAuth();
const { user, token, isLoading, isPinLocked } = useAuth();
const { isReady } = useRequireAuth();
const { showToast } = useToast();
const [groups, setGroups] = useState<FamilyGroup[]>([]);
const [name, setName] = useState('');
@@ -28,23 +28,25 @@ export default function FamilyPage() {
}, [user?.displayName]);
useEffect(() => {
if (!user || !token || isPinLocked) return;
fetchFamilyGroups(user.id, token)
const accessToken = getAccessToken() ?? token?.trim() ?? null;
if (!user || !accessToken || isPinLocked || isLoading) return;
fetchFamilyGroups(user.id, accessToken)
.then((response) => setGroups(response.groups ?? []))
.catch((error) => {
const message = getApiErrorMessage(error, 'Не удалось загрузить семью');
if (message) showToast(message);
});
}, [isPinLocked, showToast, token, user]);
}, [isLoading, isPinLocked, showToast, token, user]);
async function createGroup() {
if (!user || !token) return;
const accessToken = getAccessToken() ?? token?.trim() ?? null;
if (!user || !accessToken) return;
setCreating(true);
try {
const group = await apiFetch<FamilyGroup>('/family/groups', {
method: 'POST',
body: JSON.stringify({ ownerId: user.id, name })
}, token);
}, accessToken);
router.push(`/family/${group.id}`);
} catch (error) {
const message = getApiErrorMessage(error, 'Не удалось создать семью');
@@ -54,7 +56,7 @@ export default function FamilyPage() {
}
}
if (!isReady) {
if (!isReady || isLoading) {
return (
<IdShell active="/family">
<div className="py-20 text-center text-[#667085]">Загрузка...</div>

View File

@@ -24,7 +24,8 @@ import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { OtpInput } from '@/components/ui/otp-input';
import { ActiveDevice, apiFetch, getApiErrorMessage, SignInEvent, TotpSetupResponse, TotpStatusResponse } from '@/lib/api';
import { ActiveDevice, ActiveSession, apiFetch, AUTH_SESSION_KEY, getApiErrorMessage, SignInEvent, TotpSetupResponse, TotpStatusResponse } from '@/lib/api';
import { formatUserAgentLabel } from '@/lib/device-client';
import { cn } from '@/lib/utils';
import { QRCodeSVG } from 'qrcode.react';
import { useRequireAuth } from '@/hooks/use-require-auth';
@@ -67,7 +68,9 @@ export default function SecurityPage() {
const { isReady } = useRequireAuth();
const { showToast } = useToast();
const [devices, setDevices] = useState<ActiveDevice[]>([]);
const [sessions, setSessions] = useState<ActiveSession[]>([]);
const [history, setHistory] = useState<SignInEvent[]>([]);
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const [isSecurityLoading, setIsSecurityLoading] = useState(true);
const [isRevoking, setIsRevoking] = useState(false);
const [dialogMode, setDialogMode] = useState<DialogMode>(null);
@@ -92,13 +95,16 @@ export default function SecurityPage() {
if (!user || !token) return;
setIsSecurityLoading(true);
try {
const [devicesResponse, historyResponse, totpResponse] = await Promise.all([
const [devicesResponse, sessionsResponse, historyResponse, totpResponse] = await Promise.all([
apiFetch<{ devices: ActiveDevice[] }>(`/security/users/${user.id}/devices`, {}, token),
apiFetch<{ sessions: ActiveSession[] }>(`/security/users/${user.id}/sessions`, {}, token),
apiFetch<{ events: SignInEvent[] }>(`/security/users/${user.id}/sign-in-history`, {}, token),
apiFetch<TotpStatusResponse>(`/security/users/${user.id}/totp/status`, {}, token)
]);
setDevices(devicesResponse.devices ?? []);
setSessions(sessionsResponse.sessions ?? []);
setHistory(historyResponse.events ?? []);
setCurrentSessionId(window.localStorage.getItem(AUTH_SESSION_KEY));
setTotpEnabled(Boolean(totpResponse.isEnabled));
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось загрузить безопасность');
@@ -112,6 +118,31 @@ export default function SecurityPage() {
void loadSecurity();
}, [isReady, loadSecurity, user]);
async function revokeSession(sessionId: string) {
if (!user || !token) return;
try {
await apiFetch(`/security/users/${user.id}/sessions/${sessionId}/revoke`, { method: 'POST' }, token);
setSessions((current) => current.filter((session) => session.id !== sessionId));
showToast('Сессия завершена');
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось завершить сессию');
}
}
function sessionDeviceLabel(session: ActiveSession) {
return session.deviceName ?? formatUserAgentLabel(session.userAgent) ?? 'Неизвестное устройство';
}
function sessionMetaLine(session: ActiveSession) {
const parts = [session.ipAddress ?? 'IP не сохранён'];
const agentLabel = formatUserAgentLabel(session.userAgent);
if (agentLabel && agentLabel !== session.deviceName) {
parts.push(agentLabel);
}
parts.push(formatDate(session.updatedAt));
return parts.join(' · ');
}
async function revokeDevice(deviceId: string) {
if (!user || !token) return;
try {
@@ -335,10 +366,53 @@ export default function SecurityPage() {
return (
<IdShell active="/security">
<p className="text-sm text-[#667085]">Безопасность</p>
<h1 className="text-2xl font-medium tracking-tight sm:text-4xl">Устройства</h1>
<h1 className="text-2xl font-medium tracking-tight sm:text-4xl">Устройства и сессии</h1>
<p className="mt-2 max-w-[460px] text-base">На них вы вошли в ID и получаете уведомления безопасности от сервисов</p>
<div className="mt-8 space-y-2">
<h2 className="text-lg font-medium">Активные сессии</h2>
{isSecurityLoading ? (
<div className="rounded-[20px] bg-[#f4f5f8] px-4 py-5 text-[#667085]">Загружаем сессии...</div>
) : sessions.length ? (
sessions.map((session) => {
const Icon = deviceIcons[session.deviceType ?? 'OTHER'] ?? Laptop;
const isCurrent = session.id === currentSessionId;
return (
<div key={session.id} className="flex items-center gap-4 rounded-[20px] bg-[#f4f5f8] px-4 py-3">
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-white">
<Icon className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium">{sessionDeviceLabel(session)}</span>
{isCurrent ? (
<span className="rounded-full bg-[#e8f4ff] px-2 py-0.5 text-[11px] font-semibold text-[#3390ec]">
Текущая
</span>
) : null}
</div>
<span className="text-xs text-[#667085]">{sessionMetaLine(session)}</span>
</div>
{!isCurrent ? (
<Button
variant="ghost"
size="sm"
className="shrink-0 text-[#d14343] hover:bg-[#fbeaea]"
onClick={() => void revokeSession(session.id)}
>
Выйти
</Button>
) : null}
</div>
);
})
) : (
<div className="rounded-[20px] bg-[#f4f5f8] px-4 py-5 text-[#667085]">Активных сессий пока нет</div>
)}
</div>
<div className="mt-8 space-y-2">
<h2 className="text-lg font-medium">Другие устройства</h2>
{isSecurityLoading ? (
<div className="rounded-[20px] bg-[#f4f5f8] px-4 py-5 text-[#667085]">Загружаем устройства...</div>
) : devices.length ? (
@@ -443,10 +517,17 @@ export default function SecurityPage() {
<div className="text-[#667085]">Загружаем историю входов...</div>
) : history.length ? (
history.map((item) => (
<div key={item.id} className="flex items-center gap-4 border-b border-[#eceef4] pb-3">
<Clock3 className="h-5 w-5" />
<span className="flex-1">{item.success ? 'Успешный вход' : item.reason ?? 'Неудачная попытка входа'}</span>
<span className="text-sm text-[#667085]">{formatDate(item.createdAt)}</span>
<div key={item.id} className="flex items-start gap-4 border-b border-[#eceef4] pb-3">
<Clock3 className="mt-0.5 h-5 w-5 shrink-0" />
<div className="min-w-0 flex-1">
<p>{item.success ? 'Успешный вход' : item.reason ?? 'Неудачная попытка входа'}</p>
<p className="mt-1 text-sm text-[#667085]">
{[item.deviceName, item.ipAddress ?? 'IP не сохранён', formatUserAgentLabel(item.userAgent)]
.filter(Boolean)
.join(' · ')}
</p>
</div>
<span className="shrink-0 text-sm text-[#667085]">{formatDate(item.createdAt)}</span>
</div>
))
) : (

View File

@@ -3,6 +3,7 @@
import { useEffect } from 'react';
import { ChevronLeft, ChevronRight, Download, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { MediaImageSkeleton } from '@/components/ui/media-image-skeleton';
import type { ChatMessage } from '@/lib/api';
import { cn } from '@/lib/utils';
@@ -97,7 +98,7 @@ export function ChatImageLightbox({
// eslint-disable-next-line @next/next/no-img-element
<img src={media.blobUrl} alt="" className="max-h-[calc(100vh-140px)] max-w-full object-contain" />
) : (
<div className="text-sm text-white/70">Загрузка изображения...</div>
<MediaImageSkeleton className="h-[min(70vh,520px)] w-[min(92vw,720px)]" rounded="2xl" />
)}
</div>
@@ -137,7 +138,9 @@ export function ChatImageLightbox({
{thumb?.blobUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={thumb.blobUrl} alt="" className="h-full w-full object-cover" />
) : null}
) : (
<MediaImageSkeleton className="h-full w-full min-h-0" rounded="none" />
)}
</button>
);
})}

View File

@@ -1,12 +1,13 @@
'use client';
import type { ChatMessage } from '@/lib/api';
import { MediaImageSkeleton } from '@/components/ui/media-image-skeleton';
import { parseChatMediaMetadata } from '@/lib/chat-media-album';
import { cn } from '@/lib/utils';
interface ChatMediaAlbumGridProps {
messages: ChatMessage[];
mediaUrls: Record<string, { blobUrl?: string } | undefined>;
mediaUrls: Record<string, { blobUrl?: string; status?: string } | undefined>;
className?: string;
onImageClick?: (message: ChatMessage, index: number) => void;
}
@@ -58,6 +59,7 @@ export function ChatMediaAlbumGrid({ messages, mediaUrls, className, onImageClic
{imageMessages.map((message, index) => {
const media = message.storageKey ? mediaUrls[message.storageKey] : undefined;
const hiddenCount = count > 6 && index === 5 ? count - 6 : 0;
const isLoading = !media?.blobUrl || media.status === 'loading';
return (
<button
key={message.id}
@@ -65,11 +67,11 @@ export function ChatMediaAlbumGrid({ messages, mediaUrls, className, onImageClic
className={cn('relative overflow-hidden bg-[#dfe4ea]', tileClass(Math.min(count, 6), index))}
onClick={() => onImageClick?.(message, index)}
>
{media?.blobUrl ? (
{isLoading ? (
<MediaImageSkeleton className="h-full min-h-[96px] w-full rounded-none" rounded="none" />
) : (
// eslint-disable-next-line @next/next/no-img-element
<img src={media.blobUrl} alt="" className="h-full w-full object-cover" />
) : (
<div className="flex h-full min-h-[96px] items-center justify-center text-xs text-[#667085]"></div>
)}
{hiddenCount > 0 ? (
<div className="absolute inset-0 flex items-center justify-center bg-black/45 text-2xl font-semibold text-white">

View File

@@ -4,8 +4,11 @@ import { Bot } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { UserAvatar } from '@/components/id/user-avatar';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { useAuth } from '@/components/id/auth-provider';
import { ChatRoom, apiFetch } from '@/lib/api';
import { CHAT_ROOM_AVATAR_UPDATED_EVENT } from '@/lib/avatar-events';
import { useResilientBlobUrl } from '@/hooks/use-resilient-blob-url';
import { cn } from '@/lib/utils';
interface ChatRoomAvatarDisplayProps {
@@ -21,7 +24,9 @@ function sizeClass(size: 'sm' | 'md') {
}
export function ChatRoomAvatarDisplay({ room, viewerUserId, token, className, size = 'md' }: ChatRoomAvatarDisplayProps) {
const { isLoading: authLoading } = useAuth();
const [roomAvatarUrl, setRoomAvatarUrl] = useState<string | null>(null);
const [resolvingRoomAvatar, setResolvingRoomAvatar] = useState(false);
const peerMember = useMemo(
() =>
room.type === 'DIRECT' || room.type === 'E2E' || room.type === 'BOT'
@@ -31,34 +36,44 @@ export function ChatRoomAvatarDisplay({ room, viewerUserId, token, className, si
);
useEffect(() => {
if (!token || !room.hasAvatar || room.type === 'DIRECT' || room.type === 'E2E' || room.type === 'BOT') {
if (!token || authLoading || !room.hasAvatar || room.type === 'DIRECT' || room.type === 'E2E' || room.type === 'BOT') {
setRoomAvatarUrl(null);
setResolvingRoomAvatar(false);
return;
}
let cancelled = false;
setResolvingRoomAvatar(true);
apiFetch<{ accessUrl: string }>(`/media/chat/${room.id}/avatar/url`, {}, token)
.then((response) => {
if (!cancelled) setRoomAvatarUrl(response.accessUrl);
})
.catch(() => {
if (!cancelled) setRoomAvatarUrl(null);
})
.finally(() => {
if (!cancelled) setResolvingRoomAvatar(false);
});
return () => {
cancelled = true;
};
}, [room.hasAvatar, room.id, room.type, room.updatedAt, token]);
}, [authLoading, room.hasAvatar, room.id, room.type, room.updatedAt, token]);
useEffect(() => {
function handleRoomAvatarUpdated(event: Event) {
const detail = (event as CustomEvent<{ roomId?: string }>).detail;
if (detail?.roomId !== room.id || !room.hasAvatar || !token) return;
if (detail?.roomId !== room.id || !room.hasAvatar || !token || authLoading) return;
apiFetch<{ accessUrl: string }>(`/media/chat/${room.id}/avatar/url`, {}, token)
.then((response) => setRoomAvatarUrl(response.accessUrl))
.catch(() => setRoomAvatarUrl(null));
}
window.addEventListener(CHAT_ROOM_AVATAR_UPDATED_EVENT, handleRoomAvatarUpdated);
return () => window.removeEventListener(CHAT_ROOM_AVATAR_UPDATED_EVENT, handleRoomAvatarUpdated);
}, [room.hasAvatar, room.id, token]);
}, [authLoading, room.hasAvatar, room.id, token]);
const { blobUrl, isLoading: isLoadingRoomBlob } = useResilientBlobUrl(roomAvatarUrl, token, {
enabled: Boolean(room.hasAvatar && roomAvatarUrl && !authLoading),
label: `аватар чата ${room.name}`
});
if (peerMember && (room.type === 'DIRECT' || room.type === 'E2E')) {
return (
@@ -85,10 +100,22 @@ export function ChatRoomAvatarDisplay({ room, viewerUserId, token, className, si
);
}
if (room.hasAvatar && roomAvatarUrl) {
const isRoomAvatarLoading = Boolean(room.hasAvatar && (resolvingRoomAvatar || isLoadingRoomBlob || (roomAvatarUrl && !blobUrl)));
if (room.hasAvatar && isRoomAvatarLoading) {
return (
<Avatar className={cn(sizeClass(size), className)}>
<AvatarImage src={roomAvatarUrl} alt={room.name} />
<AvatarFallback className="bg-transparent p-0">
<Skeleton className="h-full w-full rounded-full" />
</AvatarFallback>
</Avatar>
);
}
if (room.hasAvatar && blobUrl) {
return (
<Avatar className={cn(sizeClass(size), className)}>
<AvatarImage src={blobUrl} alt={room.name} />
<AvatarFallback>{room.name.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
);

View File

@@ -65,6 +65,7 @@ import { useAuth } from '@/components/id/auth-provider';
import { useToast } from '@/components/id/toast-provider';
import { useRealtime } from '@/components/notifications/realtime-provider';
import { Button } from '@/components/ui/button';
import { MediaImageSkeleton } from '@/components/ui/media-image-skeleton';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { useRequireAuth } from '@/hooks/use-require-auth';
@@ -88,6 +89,7 @@ import {
fetchFamilyPresence,
forwardChatMessages,
getApiErrorMessage,
getAccessToken,
leaveFamilyGroup,
markChatRoomRead,
removeChatRoomMember,
@@ -197,22 +199,24 @@ function FamilySidebarAvatar({
uploading?: boolean;
onUpload: (file: File) => void;
}) {
const { isLoading: authLoading } = useAuth();
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
if (!hasAvatar || !token) {
if (!hasAvatar || !token || authLoading) {
setUrl(null);
return;
}
apiFetch<{ accessUrl: string }>(`/media/families/${groupId}/avatar/url`, {}, token)
.then((response) => setUrl(response.accessUrl))
.catch(() => setUrl(null));
}, [groupId, hasAvatar, token]);
}, [authLoading, groupId, hasAvatar, token]);
return (
<HoverUploadAvatar
name={name}
imageUrl={url}
imageToken={token}
uploading={uploading}
onFileSelect={onUpload}
className="h-11 w-11 shrink-0"
@@ -230,6 +234,10 @@ function familyMemberRoleLabel(role: string) {
return role === 'owner' ? 'Создатель семьи' : 'Участник';
}
/** Desktop: зона под fixed UserMenu + колокольчик (IdShell), на mobile не применяется */
const DESKTOP_CHAT_RIGHT_GUTTER = 'lg:pr-64';
const DESKTOP_CHAT_TOP_GUTTER = 'lg:pt-14';
function shouldSkipGroupedAlbumMessage(messages: ChatMessage[], index: number) {
const message = messages[index];
if (!message) return false;
@@ -257,7 +265,7 @@ function collectAlbumMessages(messages: ChatMessage[], startIndex: number) {
export function FamilyGroupView({ groupId }: { groupId: string }) {
const router = useRouter();
const { user, token } = useAuth();
const { user, token, isLoading: authLoading } = useAuth();
const { isReady, isPinLocked } = useRequireAuth();
const { showToast } = useToast();
const { subscribe, connected } = useRealtime();
@@ -469,9 +477,10 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
}, [isPinLocked, rooms, showToast, token]);
const loadPresence = useCallback(async () => {
if (!token || isPinLocked) return;
const accessToken = getAccessToken() ?? token?.trim() ?? null;
if (!accessToken || isPinLocked) return;
try {
const response = await fetchFamilyPresence(groupId, token);
const response = await fetchFamilyPresence(groupId, accessToken);
setPresenceMembers(response.members ?? []);
} catch {
// ignore background presence errors
@@ -479,13 +488,14 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
}, [groupId, isPinLocked, token]);
useEffect(() => {
if (!isReady || !token || isPinLocked) return;
const accessToken = getAccessToken() ?? token?.trim() ?? null;
if (!isReady || !accessToken || isPinLocked || authLoading) return;
setLoading(true);
loadGroup()
.catch((error) => showToast(getApiErrorMessage(error, 'Не удалось загрузить семью') ?? 'Ошибка'))
.finally(() => setLoading(false));
void loadPresence();
}, [isPinLocked, isReady, loadGroup, loadPresence, showToast, token]);
}, [authLoading, isPinLocked, isReady, loadGroup, loadPresence, showToast, token]);
useEffect(() => {
function handleAvatarUpdated() {
@@ -496,11 +506,12 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
}, [loadGroup]);
useEffect(() => {
if (!token || isPinLocked) return;
const accessToken = getAccessToken() ?? token?.trim() ?? null;
if (!accessToken || isPinLocked || authLoading) return;
void loadPresence();
const timer = window.setInterval(() => void loadPresence(), 25_000);
return () => window.clearInterval(timer);
}, [connected, isPinLocked, loadPresence, token]);
}, [authLoading, connected, isPinLocked, loadPresence, token]);
useEffect(() => {
if (!activeRoomId || activeRoomIsBot) return;
@@ -1699,7 +1710,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
>
{activeRoom ? (
<>
<div className="flex items-center justify-between gap-2 border-b border-[#dce3ec] bg-white px-3 py-3 sm:px-4 lg:pr-48">
<div className={cn('flex items-center justify-between gap-2 border-b border-[#dce3ec] bg-white px-3 py-3 sm:px-4', DESKTOP_CHAT_RIGHT_GUTTER)}>
<div className="flex min-w-0 flex-1 items-center gap-2 sm:gap-3">
<button
type="button"
@@ -1903,6 +1914,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
<ChatPinnedMessageBanner
senderName={pinnedMessage.senderName}
preview={pinnedPreview}
className={DESKTOP_CHAT_RIGHT_GUTTER}
onClick={() => {
if (!scrollToChatMessage(pinnedMessage.id)) {
showToast('Закреплённое сообщение пока не загружено в ленте');
@@ -1910,7 +1922,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
}}
/>
) : null}
<div className={cn('flex-1 space-y-2 overflow-y-auto px-4 py-4', isDragging && 'select-none')}>
<div className={cn('flex-1 space-y-2 overflow-y-auto px-4 py-4', DESKTOP_CHAT_RIGHT_GUTTER, DESKTOP_CHAT_TOP_GUTTER, isDragging && 'select-none')}>
{visibleMessages.map((message, messageIndex) => {
if (shouldSkipGroupedAlbumMessage(visibleMessages, messageIndex)) {
return null;
@@ -2055,10 +2067,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
}
if (!media?.blobUrl || media.status === 'loading') {
return (
<div className="flex items-center gap-2 text-sm text-[#667085]">
<Loader2 className="h-4 w-4 animate-spin" />
Загрузка файла...
</div>
<MediaImageSkeleton className="max-h-72 w-full max-w-[320px]" rounded="xl" />
);
}
if (message.type === 'IMAGE') {
@@ -2256,7 +2265,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
onDelete={() => void handleBulkDelete()}
/>
) : (
<div className="border-t border-[#dce3ec] bg-white px-2 py-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] sm:px-3 sm:py-3">
<div className={cn('border-t border-[#dce3ec] bg-white px-2 py-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] sm:px-3 sm:py-3', DESKTOP_CHAT_RIGHT_GUTTER)}>
{editingMessageId ? (
<ChatMessageEditBanner
preview={draft.trim() || messages.find((item) => item.id === editingMessageId)?.content || undefined}

View File

@@ -3,11 +3,14 @@
import { Camera, Loader2 } from 'lucide-react';
import { useId } from 'react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { useResilientBlobUrl } from '@/hooks/use-resilient-blob-url';
import { cn } from '@/lib/utils';
interface HoverUploadAvatarProps {
name: string;
imageUrl?: string | null;
imageToken?: string | null;
uploading?: boolean;
onFileSelect: (file: File) => void;
className?: string;
@@ -17,12 +20,18 @@ interface HoverUploadAvatarProps {
export function HoverUploadAvatar({
name,
imageUrl,
imageToken = null,
uploading = false,
onFileSelect,
className,
fallbackClassName
}: HoverUploadAvatarProps) {
const inputId = useId();
const { blobUrl, isLoading } = useResilientBlobUrl(imageUrl, imageToken, {
enabled: Boolean(imageUrl && imageToken),
label: `аватар ${name}`
});
const resolvedUrl = blobUrl ?? (imageToken ? null : imageUrl);
return (
<label
@@ -34,8 +43,16 @@ export function HoverUploadAvatar({
)}
>
<Avatar className="h-full w-full">
{imageUrl ? <AvatarImage src={imageUrl} alt={name} /> : null}
<AvatarFallback className={fallbackClassName}>{name.slice(0, 2).toUpperCase()}</AvatarFallback>
{isLoading && imageUrl ? (
<AvatarFallback className="bg-transparent p-0">
<Skeleton className="h-full w-full rounded-full" />
</AvatarFallback>
) : (
<>
{resolvedUrl ? <AvatarImage src={resolvedUrl} alt={name} /> : null}
<AvatarFallback className={fallbackClassName}>{name.slice(0, 2).toUpperCase()}</AvatarFallback>
</>
)}
</Avatar>
<span
className={cn(

View File

@@ -13,9 +13,10 @@ import {
AuthTokens,
ensureApiGatewayReady,
fetchAuthSession,
getDeviceFingerprint,
buildAuthDevicePayload,
IdentifyResponse,
getApiErrorMessage,
isApiGatewayReady,
isPinRequiredError,
OtpSendResponse,
PasswordlessAuthResponse,
@@ -93,10 +94,9 @@ function applySessionState(
setters.activatePinLock(session.sessionId ?? '');
} else {
setters.clearPinLock();
const accessToken = window.localStorage.getItem(AUTH_TOKEN_KEY);
if (accessToken) {
void syncFedcmSession(accessToken);
}
void ensureApiGatewayReady().then(() => {
void syncFedcmSession();
});
}
}
@@ -169,10 +169,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
void ensureApiGatewayReady().finally(() => {
initialBootstrapDoneRef.current = true;
setIsLoading(false);
if (auth.pinVerified !== false) {
void syncFedcmSession(auth.accessToken);
}
});
if (auth.pinVerified !== false) {
void syncFedcmSession(auth.accessToken);
}
},
[clearPinLock]
);
@@ -277,10 +277,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
window.localStorage.getItem(AUTH_TOKEN_KEY) || window.localStorage.getItem(AUTH_REFRESH_KEY)
);
if (hasSession && isInitialBootstrap) {
try {
await ensureApiGatewayReady();
} catch {
// Продолжаем даже если gateway не ответил в срок прогрева.
const bootstrapDeadline = Date.now() + 120_000;
while (!isApiGatewayReady() && Date.now() < bootstrapDeadline) {
await ensureApiGatewayReady(true);
if (isApiGatewayReady()) break;
await new Promise((resolve) => setTimeout(resolve, 800));
}
}
if (isInitialBootstrap) {
@@ -339,9 +340,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
body: JSON.stringify({
login: loginValue,
password,
fingerprint: getDeviceFingerprint(),
deviceName: navigator.userAgent.includes('Windows') ? 'Windows Browser' : 'Browser',
deviceType: 'WEB'
...buildAuthDevicePayload(),
})
});
if (auth.pinVerified) {
@@ -376,9 +375,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
body: JSON.stringify({
recipient,
code,
fingerprint: getDeviceFingerprint(),
deviceName: navigator.userAgent.includes('Windows') ? 'Windows Browser' : 'Browser',
deviceType: 'WEB'
...buildAuthDevicePayload(),
})
});
if (response.auth?.pinVerified) {
@@ -399,9 +396,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
body: JSON.stringify({
login: loginValue,
password: passwordValue,
fingerprint: getDeviceFingerprint(),
deviceName: navigator.userAgent.includes('Windows') ? 'Windows Browser' : 'Browser',
deviceType: 'WEB'
...buildAuthDevicePayload(),
})
});
if (auth.pinVerified) {
@@ -422,9 +417,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
body: JSON.stringify({
username,
password: passwordValue,
fingerprint: getDeviceFingerprint(),
deviceName: navigator.userAgent.includes('Windows') ? 'Windows Browser' : 'Browser',
deviceType: 'WEB'
...buildAuthDevicePayload(),
})
});
if (auth.pinVerified) {
@@ -443,9 +436,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
method: 'POST',
body: JSON.stringify({
recipient,
fingerprint: getDeviceFingerprint(),
deviceName: navigator.userAgent.includes('Windows') ? 'Windows Browser' : 'Browser',
deviceType: 'WEB'
...buildAuthDevicePayload(),
})
});
return response.totpChallengeToken;

View File

@@ -3,6 +3,7 @@
import { useRef, useState } from 'react';
import { Camera, Loader2 } from 'lucide-react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { useToast } from '@/components/id/toast-provider';
import { useAvatarUrl } from '@/hooks/use-avatar-url';
import { apiFetch, getApiErrorMessage, uploadMediaObject } from '@/lib/api';
@@ -41,7 +42,7 @@ export function AvatarUpload({
const inputRef = useRef<HTMLInputElement>(null);
const { showToast } = useToast();
const [isUploading, setIsUploading] = useState(false);
const { avatarUrl, refreshAvatarUrl } = useAvatarUrl(userId, hasAvatar, token);
const { avatarUrl, isAvatarLoading, refreshAvatarUrl } = useAvatarUrl(userId, hasAvatar, token);
const initials = (displayName ?? '').slice(0, 2).toUpperCase() || 'ID';
async function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
@@ -99,8 +100,16 @@ export function AvatarUpload({
onClick={openFilePicker}
>
<Avatar className={cn('border-4 border-white shadow-xl', className)}>
{avatarUrl ? <AvatarImage src={avatarUrl} alt={displayName ?? 'Аватар'} /> : null}
<AvatarFallback className="bg-[linear-gradient(135deg,#6f58ff,#ffd2a8)] text-2xl">{initials}</AvatarFallback>
{isAvatarLoading && hasAvatar ? (
<AvatarFallback className="bg-transparent p-0">
<Skeleton className="h-full w-full rounded-full" />
</AvatarFallback>
) : (
<>
{avatarUrl ? <AvatarImage src={avatarUrl} alt={displayName ?? 'Аватар'} /> : null}
<AvatarFallback className="bg-[linear-gradient(135deg,#6f58ff,#ffd2a8)] text-2xl">{initials}</AvatarFallback>
</>
)}
</Avatar>
<span
className={cn(
@@ -146,14 +155,22 @@ export function AvatarDisplay({
verificationIcon?: string | null;
badgeSize?: 'xs' | 'sm' | 'md' | 'lg';
}) {
const { avatarUrl } = useAvatarUrl(userId, hasAvatar, token);
const { avatarUrl, isAvatarLoading } = useAvatarUrl(userId, hasAvatar, token);
const initials = (displayName ?? '').slice(0, 2).toUpperCase() || 'ID';
return (
<div className="relative inline-flex shrink-0">
<Avatar className={className}>
{avatarUrl ? <AvatarImage src={avatarUrl} alt={displayName ?? 'Аватар'} /> : null}
<AvatarFallback className="bg-[linear-gradient(135deg,#6f58ff,#ffd2a8)]">{initials}</AvatarFallback>
{isAvatarLoading && hasAvatar ? (
<AvatarFallback className="bg-transparent p-0">
<Skeleton className="h-full w-full rounded-full" />
</AvatarFallback>
) : (
<>
{avatarUrl ? <AvatarImage src={avatarUrl} alt={displayName ?? 'Аватар'} /> : null}
<AvatarFallback className="bg-[linear-gradient(135deg,#6f58ff,#ffd2a8)]">{initials}</AvatarFallback>
</>
)}
</Avatar>
{isVerified ? (
<VerificationBadge

View File

@@ -1,6 +1,7 @@
'use client';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { VerificationBadge } from '@/components/id/verification-badge';
import { useAvatarUrl } from '@/hooks/use-avatar-url';
import { cn } from '@/lib/utils';
@@ -26,7 +27,7 @@ export function UserAvatar({
badgeSize?: 'xs' | 'sm' | 'md' | 'lg';
fallbackClassName?: string;
}) {
const { avatarUrl } = useAvatarUrl(userId, hasAvatar, token);
const { avatarUrl, isAvatarLoading } = useAvatarUrl(userId, hasAvatar, token);
const initials = (displayName ?? '')
.split(' ')
.map((part) => part[0])
@@ -37,10 +38,18 @@ export function UserAvatar({
return (
<div className="relative inline-flex shrink-0">
<Avatar className={className}>
{avatarUrl ? <AvatarImage src={avatarUrl} alt={displayName ?? 'Аватар'} /> : null}
<AvatarFallback className={cn('bg-[linear-gradient(135deg,#6f58ff,#ffd2a8)] text-xs font-semibold', fallbackClassName)}>
{initials.slice(0, 2)}
</AvatarFallback>
{isAvatarLoading && hasAvatar ? (
<AvatarFallback className="bg-transparent p-0">
<Skeleton className="h-full w-full rounded-full" />
</AvatarFallback>
) : (
<>
{avatarUrl ? <AvatarImage src={avatarUrl} alt={displayName ?? 'Аватар'} /> : null}
<AvatarFallback className={cn('bg-[linear-gradient(135deg,#6f58ff,#ffd2a8)] text-xs font-semibold', fallbackClassName)}>
{initials.slice(0, 2)}
</AvatarFallback>
</>
)}
</Avatar>
{isVerified ? (
<VerificationBadge

View File

@@ -101,6 +101,7 @@ export function RealtimeProvider({ children }: { children: React.ReactNode }) {
data.type === 'family_invite' ||
data.type === 'role_assigned' ||
data.type === 'role_removed' ||
data.type === 'login_success' ||
(data.type === 'chat_message' && data.payload?.notificationId)
) {
setUnreadCount((count) => count + 1);

View File

@@ -0,0 +1,22 @@
'use client';
import { cn } from '@/lib/utils';
import { Skeleton } from '@/components/ui/skeleton';
export function MediaImageSkeleton({
className,
rounded = 'xl'
}: {
className?: string;
rounded?: 'xl' | '2xl' | 'none' | 'full';
}) {
const roundedClass =
rounded === '2xl' ? 'rounded-2xl' : rounded === 'full' ? 'rounded-full' : rounded === 'none' ? 'rounded-none' : 'rounded-xl';
return (
<Skeleton
className={cn('relative min-h-[120px] w-full overflow-hidden', roundedClass, className)}
aria-hidden
/>
);
}

View File

@@ -0,0 +1,12 @@
'use client';
import { cn } from '@/lib/utils';
export function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn('animate-pulse rounded-md bg-gradient-to-r from-[#e8ebf0] via-[#f4f6f9] to-[#e8ebf0] bg-[length:200%_100%]', className)}
{...props}
/>
);
}

View File

@@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from 'react';
import { useAuth } from '@/components/id/auth-provider';
import { apiFetch } from '@/lib/api';
import { AVATAR_UPDATED_EVENT } from '@/lib/avatar-events';
import { useResilientBlobUrl } from '@/hooks/use-resilient-blob-url';
interface AvatarAccessResponse {
accessUrl: string;
@@ -12,41 +13,61 @@ interface AvatarAccessResponse {
export function useAvatarUrl(userId: string | undefined, hasAvatar: boolean | undefined, token: string | null) {
const { isPinLocked, isLoading } = useAuth();
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
const [accessUrl, setAccessUrl] = useState<string | null>(null);
const [resolvingAccessUrl, setResolvingAccessUrl] = useState(false);
const refresh = useCallback(async () => {
const refreshAccessUrl = useCallback(async () => {
if (!userId || !token || !hasAvatar || isPinLocked || isLoading) {
setAvatarUrl(null);
setAccessUrl(null);
setResolvingAccessUrl(false);
return;
}
setResolvingAccessUrl(true);
try {
const response = await apiFetch<AvatarAccessResponse>(`/media/avatars/${userId}/url`, {}, token);
setAvatarUrl(response.accessUrl);
setAccessUrl(response.accessUrl);
} catch {
setAvatarUrl(null);
setAccessUrl(null);
} finally {
setResolvingAccessUrl(false);
}
}, [hasAvatar, isLoading, isPinLocked, token, userId]);
useEffect(() => {
void refresh();
void refreshAccessUrl();
if (!hasAvatar) return;
const timer = window.setInterval(() => {
void refresh();
void refreshAccessUrl();
}, 14 * 60 * 1000);
return () => window.clearInterval(timer);
}, [hasAvatar, refresh]);
}, [hasAvatar, refreshAccessUrl]);
useEffect(() => {
if (!userId || !hasAvatar) return;
function handleAvatarUpdated(event: Event) {
const detail = (event as CustomEvent<{ userId?: string }>).detail;
if (detail?.userId === userId) {
void refresh();
void refreshAccessUrl();
}
}
window.addEventListener(AVATAR_UPDATED_EVENT, handleAvatarUpdated);
return () => window.removeEventListener(AVATAR_UPDATED_EVENT, handleAvatarUpdated);
}, [hasAvatar, refresh, userId]);
}, [hasAvatar, refreshAccessUrl, userId]);
return { avatarUrl, refreshAvatarUrl: refresh };
const {
blobUrl,
isLoading: isLoadingBlob,
hasError
} = useResilientBlobUrl(accessUrl, token, {
enabled: Boolean(hasAvatar && accessUrl && !isPinLocked && !isLoading),
label: `аватар ${userId ?? ''}`.trim()
});
const avatarUrl = blobUrl;
const isAvatarLoading = Boolean(hasAvatar && (resolvingAccessUrl || isLoadingBlob || (accessUrl && !blobUrl && !hasError)));
const refreshAvatarUrl = useCallback(async () => {
await refreshAccessUrl();
}, [refreshAccessUrl]);
return { avatarUrl, isAvatarLoading, hasAvatarError: hasError, refreshAvatarUrl };
}

View File

@@ -8,7 +8,8 @@ import {
FamilyPresenceMember,
fetchFamilyGroup,
fetchFamilyGroups,
fetchFamilyPresence
fetchFamilyPresence,
getAccessToken
} from '@/lib/api';
export function useSelectedFamily(enabled = true) {
@@ -20,7 +21,8 @@ export function useSelectedFamily(enabled = true) {
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
if (!enabled || !user || !token || isPinLocked || isLoading) {
const accessToken = getAccessToken() ?? token?.trim() ?? null;
if (!enabled || !user || !accessToken || isPinLocked || isLoading) {
setGroups([]);
setGroup(null);
setPresenceMembers([]);
@@ -29,7 +31,7 @@ export function useSelectedFamily(enabled = true) {
}
try {
const groupsResponse = await fetchFamilyGroups(user.id, token);
const groupsResponse = await fetchFamilyGroups(user.id, accessToken);
const list = groupsResponse.groups ?? [];
setGroups(list);
@@ -48,8 +50,8 @@ export function useSelectedFamily(enabled = true) {
}
const [groupResponse, presenceResponse] = await Promise.all([
fetchFamilyGroup(effectiveId, token),
fetchFamilyPresence(effectiveId, token)
fetchFamilyGroup(effectiveId, accessToken),
fetchFamilyPresence(effectiveId, accessToken)
]);
setGroup(groupResponse);
setPresenceMembers(presenceResponse.members ?? []);
@@ -68,7 +70,8 @@ export function useSelectedFamily(enabled = true) {
}, [refresh]);
useEffect(() => {
if (!enabled || !token || isPinLocked || isLoading || !group) return;
const accessToken = getAccessToken() ?? token?.trim() ?? null;
if (!enabled || !accessToken || isPinLocked || isLoading || !group) return;
const timer = window.setInterval(() => void refresh(), 25_000);
return () => window.clearInterval(timer);
}, [enabled, group, isLoading, isPinLocked, refresh, token]);

View File

@@ -0,0 +1,60 @@
'use client';
import { useEffect, useState } from 'react';
import { createBlobObjectUrl, revokeBlobObjectUrl } from '@/lib/authenticated-media';
import { fetchMediaBlobWithRetry } from '@/lib/resilient-media-fetch';
export type ResilientBlobStatus = 'idle' | 'loading' | 'ready' | 'error';
export function useResilientBlobUrl(
remoteUrl: string | null | undefined,
token: string | null | undefined,
options?: { enabled?: boolean; label?: string; mimeType?: string }
) {
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const [status, setStatus] = useState<ResilientBlobStatus>('idle');
useEffect(() => {
const enabled = options?.enabled !== false;
if (!remoteUrl || !token || !enabled) {
setBlobUrl(null);
setStatus('idle');
return;
}
let cancelled = false;
let objectUrl: string | null = null;
async function load() {
setStatus('loading');
try {
const blob = await fetchMediaBlobWithRetry(remoteUrl!, token!, {
mimeType: options?.mimeType,
label: options?.label
});
if (cancelled) return;
objectUrl = createBlobObjectUrl(blob);
setBlobUrl(objectUrl);
setStatus('ready');
} catch {
if (cancelled) return;
setBlobUrl(null);
setStatus('error');
}
}
void load();
return () => {
cancelled = true;
revokeBlobObjectUrl(objectUrl ?? undefined);
};
}, [options?.enabled, options?.label, options?.mimeType, remoteUrl, token]);
return {
blobUrl,
status,
isLoading: status === 'loading',
hasError: status === 'error'
};
}

View File

@@ -219,13 +219,14 @@ export interface QrLoginSession {
auth?: AuthTokens & { user?: PublicUser };
}
export async function createQrLoginSession(deviceName: string) {
export async function createQrLoginSession(deviceName?: string) {
const { buildAuthDevicePayload } = await import('@/lib/device-client');
const device = buildAuthDevicePayload();
return apiFetch<QrLoginSession>('/auth/advanced/qr/session', {
method: 'POST',
body: JSON.stringify({
deviceName,
fingerprint: getDeviceFingerprint(),
deviceType: 'WEB'
...device,
deviceName: deviceName?.trim() || device.deviceName
})
});
}
@@ -368,6 +369,19 @@ export const AUTH_REFRESH_KEY = 'lendry_refresh_token';
export const AUTH_SESSION_KEY = 'lendry_session_id';
export const AUTH_USER_CACHE_KEY = 'lendry_cached_user';
/** Актуальный access token из localStorage (не полагается на React state). */
export function getAccessToken(): string | null {
if (typeof window === 'undefined') return null;
const value = window.localStorage.getItem(AUTH_TOKEN_KEY)?.trim();
return value || null;
}
function resolveAccessToken(explicit?: string | null): string | null {
const trimmed = explicit?.trim();
if (trimmed) return trimmed;
return getAccessToken();
}
export class ApiError extends Error {
status: number;
code?: string;
@@ -441,6 +455,7 @@ export function isGatewayUnavailableError(error: unknown): boolean {
export function getApiErrorMessage(error: unknown, fallback: string): string | null {
if (isPinRequiredError(error)) return null;
if (isGatewayUnavailableError(error)) return null;
if (error instanceof ApiError && error.code === 'NO_TOKEN') return null;
if (error instanceof ApiError && error.status === 403 && error.message.toLowerCase().includes('pin')) {
return null;
}
@@ -526,15 +541,18 @@ export async function refreshAuthSession(): Promise<RefreshSessionResponse> {
return response.json() as Promise<RefreshSessionResponse>;
}
export async function syncFedcmSession(accessToken: string) {
if (typeof window === 'undefined' || !accessToken.trim()) return;
export async function syncFedcmSession(accessToken?: string | null) {
const token = resolveAccessToken(accessToken);
if (typeof window === 'undefined' || !token) return;
try {
await ensureApiGatewayReady();
await fetchWithGatewayRetry(`${getApiUrl()}/fedcm/session/sync`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken.trim()}` },
credentials: 'include'
});
await apiFetch<{ synced: boolean }>(
'/fedcm/session/sync',
{
method: 'POST',
credentials: 'include'
},
token
);
} catch {
// фоновая синхронизация FedCM cookie
}
@@ -602,6 +620,7 @@ export interface SignInEvent {
reason?: string | null;
ipAddress?: string | null;
userAgent?: string | null;
deviceName?: string | null;
createdAt: string;
}
@@ -609,6 +628,8 @@ export interface ActiveSession {
id: string;
userId: string;
deviceId?: string | null;
deviceName?: string | null;
deviceType?: string | null;
pinVerified: boolean;
status: string;
ipAddress?: string | null;
@@ -633,10 +654,11 @@ export function buildSystemSettingPayload(setting: Pick<SystemSetting, 'key' | '
}
const GATEWAY_RETRY_STATUS = new Set([502, 503, 504]);
const GATEWAY_RETRY_ATTEMPTS = 6;
const GATEWAY_WARMUP_MAX_ATTEMPTS = 40;
const GATEWAY_WARMUP_DELAY_MS = 300;
const GATEWAY_RETRY_ATTEMPTS = 8;
const GATEWAY_WARMUP_MAX_ATTEMPTS = 80;
const GATEWAY_WARMUP_DELAY_MS = 400;
const GATEWAY_READY_CONSECUTIVE_OK = 2;
const GATEWAY_WARMUP_MAX_DELAY_MS = 2500;
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
@@ -650,6 +672,15 @@ export function resetApiGatewayWarmup() {
gatewayReadyPromise = null;
}
export function invalidateApiGatewayReady() {
gatewayReadyResolved = false;
gatewayReadyPromise = null;
}
export function isApiGatewayReady() {
return gatewayReadyResolved;
}
export function markApiGatewayReady() {
gatewayReadyResolved = true;
}
@@ -671,8 +702,12 @@ export async function ensureApiGatewayReady(force = false): Promise<void> {
if (typeof window === 'undefined') return;
if (gatewayReadyResolved && !force) return;
if (gatewayReadyPromise && !force) return gatewayReadyPromise;
if (force) {
gatewayReadyResolved = false;
gatewayReadyPromise = null;
}
gatewayReadyPromise = (async () => {
await sleep(600);
let consecutiveOk = 0;
for (let attempt = 0; attempt < GATEWAY_WARMUP_MAX_ATTEMPTS; attempt++) {
if (await probeGatewayReady()) {
@@ -684,9 +719,10 @@ export async function ensureApiGatewayReady(force = false): Promise<void> {
} else {
consecutiveOk = 0;
}
await sleep(GATEWAY_WARMUP_DELAY_MS);
const delay = Math.min(GATEWAY_WARMUP_DELAY_MS * 1.35 ** attempt, GATEWAY_WARMUP_MAX_DELAY_MS);
await sleep(delay);
}
gatewayReadyResolved = true;
gatewayReadyPromise = null;
})();
return gatewayReadyPromise;
@@ -699,6 +735,8 @@ async function fetchWithGatewayRetry(url: string, init: RequestInit): Promise<Re
try {
const response = await fetch(url, init);
if (GATEWAY_RETRY_STATUS.has(response.status) && attempt + 1 < GATEWAY_RETRY_ATTEMPTS) {
invalidateApiGatewayReady();
await ensureApiGatewayReady(true);
await sleep(400 * (attempt + 1));
continue;
}
@@ -706,6 +744,8 @@ async function fetchWithGatewayRetry(url: string, init: RequestInit): Promise<Re
} catch (error) {
lastNetworkError = error;
if (attempt + 1 < GATEWAY_RETRY_ATTEMPTS) {
invalidateApiGatewayReady();
await ensureApiGatewayReady(true);
await sleep(400 * (attempt + 1));
continue;
}
@@ -716,11 +756,11 @@ async function fetchWithGatewayRetry(url: string, init: RequestInit): Promise<Re
}
export async function apiFetch<T>(path: string, options: RequestInit = {}, token?: string | null, allowRetry = true): Promise<T> {
if (typeof window !== 'undefined' && path !== '/health' && !path.startsWith('/health?')) {
if (typeof window !== 'undefined' && !path.startsWith('/health')) {
await ensureApiGatewayReady();
}
const resolvedToken = token ?? (typeof window !== 'undefined' ? window.localStorage.getItem(AUTH_TOKEN_KEY) : null);
const resolvedToken = resolveAccessToken(token);
const method = (options.method ?? 'GET').toUpperCase();
const hasBody = options.body !== undefined && options.body !== null;
const headers: Record<string, string> = {
@@ -762,21 +802,19 @@ export async function apiFetch<T>(path: string, options: RequestInit = {}, token
}
}
if (allowRetry && isGatewayUnavailableError(error)) {
invalidateApiGatewayReady();
await ensureApiGatewayReady(true);
return apiFetch<T>(path, options, token, false);
}
throw error;
}
return response.json() as Promise<T>;
}
export function getDeviceFingerprint(): string {
if (typeof window === 'undefined') return 'server';
const key = 'lendry_device_fingerprint';
const existing = window.localStorage.getItem(key);
if (existing) return existing;
const value = crypto.randomUUID();
window.localStorage.setItem(key, value);
return value;
}
export { getDeviceFingerprint, buildAuthDevicePayload, resolveDeviceLabel, formatUserAgentLabel } from '@/lib/device-client';
export interface MediaAccessResponse {
accessUrl: string;
@@ -1022,7 +1060,11 @@ export interface ChatRoom {
}
export async function fetchFamilyGroups(userId: string, token?: string | null) {
return apiFetch<{ groups?: FamilyGroup[] }>(`/family/users/${userId}/groups`, {}, token);
const accessToken = resolveAccessToken(token);
if (!accessToken) {
throw new ApiError('Не авторизован', 401, 'NO_TOKEN');
}
return apiFetch<{ groups?: FamilyGroup[] }>(`/family/users/${userId}/groups`, {}, accessToken);
}
export async function fetchFamilyGroup(groupId: string, token?: string | null) {
@@ -1339,7 +1381,11 @@ export async function reportChatTyping(roomId: string, token?: string | null) {
}
export async function fetchFamilyPresence(groupId: string, token?: string | null) {
return apiFetch<FamilyPresence>(`/family/groups/${groupId}/presence`, {}, token);
const accessToken = resolveAccessToken(token);
if (!accessToken) {
throw new ApiError('Не авторизован', 401, 'NO_TOKEN');
}
return apiFetch<FamilyPresence>(`/family/groups/${groupId}/presence`, {}, accessToken);
}
/** @deprecated Используйте getWsUrl() — URL может переключаться на same-origin /idp-api/ws в браузере */

View File

@@ -1,15 +1,7 @@
import { fetchMediaBlobWithRetry } from '@/lib/resilient-media-fetch';
export async function fetchAuthenticatedMediaBlob(accessUrl: string, token: string, mimeType?: string) {
const response = await fetch(accessUrl, {
headers: { Authorization: `Bearer ${token}` }
});
if (!response.ok) {
throw new Error('Не удалось загрузить файл');
}
const raw = await response.blob();
if (mimeType && (!raw.type || raw.type === 'application/octet-stream')) {
return new Blob([await raw.arrayBuffer()], { type: mimeType });
}
return raw;
return fetchMediaBlobWithRetry(accessUrl, token, { mimeType, label: 'файл чата' });
}
export function createBlobObjectUrl(blob: Blob) {

View File

@@ -0,0 +1,81 @@
export function getDeviceFingerprint(): string {
if (typeof window === 'undefined') return 'server';
const key = 'lendry_device_fingerprint';
const existing = window.localStorage.getItem(key);
if (existing) return existing;
const value = crypto.randomUUID();
window.localStorage.setItem(key, value);
return value;
}
export function resolveDeviceType(): string {
if (typeof navigator === 'undefined') return 'WEB';
const ua = navigator.userAgent;
if (/Android/i.test(ua)) return 'ANDROID';
if (/iPhone|iPad|iPod/i.test(ua)) return 'IOS';
if (/Windows/i.test(ua)) return 'DESKTOP';
if (/Mac OS X/i.test(ua)) return 'DESKTOP';
return 'WEB';
}
export function resolveDeviceLabel(): string {
if (typeof navigator === 'undefined') return 'Browser';
const ua = navigator.userAgent;
const browser = (() => {
if (/Edg\//i.test(ua)) return 'Microsoft Edge';
if (/YaBrowser/i.test(ua)) return 'Яндекс Браузер';
if (/Firefox\//i.test(ua)) return 'Firefox';
if (/CriOS/i.test(ua)) return 'Chrome';
if (/Chrome\//i.test(ua) && !/Edg\//i.test(ua)) return 'Chrome';
if (/Safari/i.test(ua) && !/Chrome/i.test(ua)) return 'Safari';
return 'Браузер';
})();
const os = (() => {
if (/Windows NT/i.test(ua)) return 'Windows';
if (/Android/i.test(ua)) return 'Android';
if (/iPhone/i.test(ua)) return 'iPhone';
if (/iPad/i.test(ua)) return 'iPad';
if (/Mac OS X/i.test(ua)) return 'macOS';
if (/Linux/i.test(ua)) return 'Linux';
return 'Устройство';
})();
return `${browser} · ${os}`;
}
export function buildAuthDevicePayload() {
return {
fingerprint: getDeviceFingerprint(),
deviceName: resolveDeviceLabel(),
deviceType: resolveDeviceType()
};
}
export function formatUserAgentLabel(userAgent?: string | null): string | null {
if (!userAgent?.trim()) return null;
const ua = userAgent.trim();
const browser = (() => {
if (/Edg\//i.test(ua)) return 'Microsoft Edge';
if (/YaBrowser/i.test(ua)) return 'Яндекс Браузер';
if (/Firefox\//i.test(ua)) return 'Firefox';
if (/Chrome\//i.test(ua) && !/Edg\//i.test(ua)) return 'Chrome';
if (/Safari/i.test(ua) && !/Chrome/i.test(ua)) return 'Safari';
return null;
})();
const os = (() => {
if (/Windows NT/i.test(ua)) return 'Windows';
if (/Android/i.test(ua)) return 'Android';
if (/iPhone/i.test(ua)) return 'iPhone';
if (/iPad/i.test(ua)) return 'iPad';
if (/Mac OS X/i.test(ua)) return 'macOS';
if (/Linux/i.test(ua)) return 'Linux';
return null;
})();
if (browser && os) return `${browser} · ${os}`;
return browser ?? os;
}

View File

@@ -0,0 +1,60 @@
import { ensureApiGatewayReady, invalidateApiGatewayReady } from '@/lib/api';
const GATEWAY_RETRY_STATUS = new Set([502, 503, 504]);
const MEDIA_FETCH_MAX_ATTEMPTS = 8;
const MEDIA_SLOW_LOAD_WARN_MS = 8_000;
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function warnSlowMediaLoad(label: string, startedAt: number, error: unknown) {
if (Date.now() - startedAt < MEDIA_SLOW_LOAD_WARN_MS) return;
const detail = error instanceof Error ? error.message : 'неизвестная ошибка';
console.warn(`[media] ${label}: не удалось загрузить за ${Math.round((Date.now() - startedAt) / 1000)} с (${detail})`);
}
export async function fetchMediaBlobWithRetry(
accessUrl: string,
token: string,
options?: { mimeType?: string; label?: string }
): Promise<Blob> {
const startedAt = Date.now();
const label = options?.label ?? 'изображение';
let lastError: unknown;
for (let attempt = 0; attempt < MEDIA_FETCH_MAX_ATTEMPTS; attempt += 1) {
try {
await ensureApiGatewayReady();
const response = await fetch(accessUrl, {
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store'
});
if (GATEWAY_RETRY_STATUS.has(response.status) && attempt + 1 < MEDIA_FETCH_MAX_ATTEMPTS) {
invalidateApiGatewayReady();
await sleep(350 * (attempt + 1));
continue;
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const raw = await response.blob();
const mimeType = options?.mimeType;
if (mimeType && (!raw.type || raw.type === 'application/octet-stream')) {
return new Blob([await raw.arrayBuffer()], { type: mimeType });
}
return raw;
} catch (error) {
lastError = error;
if (attempt + 1 < MEDIA_FETCH_MAX_ATTEMPTS) {
await sleep(350 * (attempt + 1));
}
}
}
warnSlowMediaLoad(label, startedAt, lastError);
throw lastError instanceof Error ? lastError : new Error('Не удалось загрузить файл');
}