From 57cb58347b2b65db1d474a76844535ed8c80c674 Mon Sep 17 00:00:00 2001 From: lendry Date: Mon, 29 Jun 2026 22:51:25 +0300 Subject: [PATCH] fix and update --- apps/api-gateway/src/client-request.util.ts | 36 ++++++ .../controllers/advanced-auth.controller.ts | 20 ++-- .../src/controllers/auth.controller.ts | 32 +++--- .../src/controllers/fedcm.controller.ts | 38 ++++-- .../src/controllers/security.controller.ts | 9 +- apps/frontend/app/auth/login/page.tsx | 4 +- apps/frontend/app/family/page.tsx | 20 ++-- apps/frontend/app/security/page.tsx | 95 +++++++++++++-- .../components/chat/chat-image-lightbox.tsx | 7 +- .../components/chat/chat-media-album-grid.tsx | 10 +- .../family/chat-room-avatar-display.tsx | 39 ++++++- .../components/family/family-group-view.tsx | 41 ++++--- .../components/family/hover-upload-avatar.tsx | 21 +++- apps/frontend/components/id/auth-provider.tsx | 45 +++----- apps/frontend/components/id/avatar-upload.tsx | 29 ++++- apps/frontend/components/id/user-avatar.tsx | 19 ++- .../notifications/realtime-provider.tsx | 1 + .../components/ui/media-image-skeleton.tsx | 22 ++++ apps/frontend/components/ui/skeleton.tsx | 12 ++ apps/frontend/hooks/use-avatar-url.ts | 43 +++++-- apps/frontend/hooks/use-primary-family.ts | 15 ++- apps/frontend/hooks/use-resilient-blob-url.ts | 60 ++++++++++ apps/frontend/lib/api.ts | 108 +++++++++++++----- apps/frontend/lib/authenticated-media.ts | 14 +-- apps/frontend/lib/device-client.ts | 81 +++++++++++++ apps/frontend/lib/resilient-media-fetch.ts | 60 ++++++++++ .../src/domain/auth-grpc.controller.ts | 17 ++- apps/sso-core/src/domain/auth.service.ts | 39 ++++++- apps/sso-core/src/infra/client-device.util.ts | 48 ++++++++ install.sh | 1 + 30 files changed, 799 insertions(+), 187 deletions(-) create mode 100644 apps/api-gateway/src/client-request.util.ts create mode 100644 apps/frontend/components/ui/media-image-skeleton.tsx create mode 100644 apps/frontend/components/ui/skeleton.tsx create mode 100644 apps/frontend/hooks/use-resilient-blob-url.ts create mode 100644 apps/frontend/lib/device-client.ts create mode 100644 apps/frontend/lib/resilient-media-fetch.ts create mode 100644 apps/sso-core/src/infra/client-device.util.ts diff --git a/apps/api-gateway/src/client-request.util.ts b/apps/api-gateway/src/client-request.util.ts new file mode 100644 index 0000000..9f279d0 --- /dev/null +++ b/apps/api-gateway/src/client-request.util.ts @@ -0,0 +1,36 @@ +import type { Request } from 'express'; + +export function resolveClientIp(req: Pick): string | undefined { + const forwarded = req.headers['x-forwarded-for']; + if (typeof forwarded === 'string') { + const first = forwarded.split(',')[0]?.trim(); + if (first) return first; + } + if (Array.isArray(forwarded)) { + const first = forwarded[0]?.trim(); + if (first) return first; + } + + const realIp = req.headers['x-real-ip']; + if (typeof realIp === 'string' && realIp.trim()) { + return realIp.trim(); + } + + const ip = req.ip?.replace(/^::ffff:/, '').trim(); + return ip || undefined; +} + +export function resolveClientUserAgent(req: Pick): string | undefined { + const value = req.headers['user-agent']; + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed || undefined; +} + +export function enrichAuthClientMeta(req: Pick, dto: T) { + return { + ...dto, + ipAddress: resolveClientIp(req), + userAgent: resolveClientUserAgent(req) + }; +} diff --git a/apps/api-gateway/src/controllers/advanced-auth.controller.ts b/apps/api-gateway/src/controllers/advanced-auth.controller.ts index 6717b0a..921629c 100644 --- a/apps/api-gateway/src/controllers/advanced-auth.controller.ts +++ b/apps/api-gateway/src/controllers/advanced-auth.controller.ts @@ -1,4 +1,6 @@ -import { Body, Controller, Get, Headers, Ip, Param, Post } from '@nestjs/common'; +import { Body, Controller, Get, Headers, Param, Post, Req } from '@nestjs/common'; +import type { Request } from 'express'; +import { enrichAuthClientMeta } from '../client-request.util'; import { JwtService } from '@nestjs/jwt'; import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; import { CoreGrpcService } from '../core-grpc.service'; @@ -33,14 +35,14 @@ export class AdvancedAuthController { @ApiOperation({ summary: 'Создать QR-сессию', description: 'Создает временную QR-сессию для входа с другого устройства.' }) @ApiBody({ type: QrSessionDto }) @ApiResponse({ status: 201, description: 'QR-сессия создана' }) - createQr(@Body() dto: QrSessionDto, @Ip() ipAddress?: string, @Headers('user-agent') userAgent?: string) { - return this.core.advancedAuth.CreateQrSession({ - deviceName: dto.deviceName, - fingerprint: dto.fingerprint, - deviceType: dto.deviceType ?? 'WEB', - ipAddress, - userAgent - }); + createQr(@Body() dto: QrSessionDto, @Req() req: Request) { + return this.core.advancedAuth.CreateQrSession( + enrichAuthClientMeta(req, { + deviceName: dto.deviceName, + fingerprint: dto.fingerprint, + deviceType: dto.deviceType ?? 'WEB' + }) + ); } @Get('qr/session/:sessionId') diff --git a/apps/api-gateway/src/controllers/auth.controller.ts b/apps/api-gateway/src/controllers/auth.controller.ts index 03855d2..1894648 100644 --- a/apps/api-gateway/src/controllers/auth.controller.ts +++ b/apps/api-gateway/src/controllers/auth.controller.ts @@ -1,8 +1,10 @@ -import { Body, Controller, Get, Headers, Ip, Post, UseInterceptors } from '@nestjs/common'; +import { Body, Controller, Get, Headers, Post, Req, UseInterceptors } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; +import type { Request } from 'express'; import { firstValueFrom } from 'rxjs'; import { CoreGrpcService } from '../core-grpc.service'; +import { enrichAuthClientMeta } from '../client-request.util'; import { BeginTotpLoginDto, IdentifyDto, LdapLoginDto, LoginDto, PasswordlessOtpDto, PasswordlessVerifyDto, PasswordLoginDto, RefreshSessionDto, RegisterDto, VerifyPinDto, VerifyTotpLoginDto } from '../dto/auth.dto'; import { resolveAuthorizedPayload, verifyAccessToken } from '../session-auth'; import { FedcmCookieInterceptor } from '../interceptors/fedcm-cookie.interceptor'; @@ -26,8 +28,8 @@ export class AuthController { @Post('login') @ApiOperation({ summary: 'Вход по почте, телефону или логину', description: 'Возвращает JWT и refresh token. Если PIN включен, сессия создается в ограниченном режиме.' }) @ApiBody({ type: LoginDto }) - login(@Body() dto: LoginDto) { - return this.core.auth.Login(dto); + login(@Body() dto: LoginDto, @Req() req: Request) { + return this.core.auth.Login(enrichAuthClientMeta(req, dto)); } @Post('identify') @@ -40,29 +42,29 @@ export class AuthController { @Post('otp/send') @ApiOperation({ summary: 'Отправить OTP для входа', description: 'Passwordless-first вход: пользователь вводит почту или телефон, сервер создает 6-значный код и пишет его в console.log.' }) @ApiBody({ type: PasswordlessOtpDto }) - sendOtp(@Body() dto: PasswordlessOtpDto, @Ip() ipAddress?: string) { - return this.core.auth.SendOtp({ recipient: dto.recipient, channel: dto.channel, ipAddress }); + sendOtp(@Body() dto: PasswordlessOtpDto, @Req() req: Request) { + return this.core.auth.SendOtp({ recipient: dto.recipient, channel: dto.channel, ipAddress: enrichAuthClientMeta(req, {}).ipAddress }); } @Post('otp/verify') @ApiOperation({ summary: 'Проверить OTP для входа', description: 'Если пользователь не существует, создает его. Если пароль не задан, сразу возвращает JWT. Если пароль задан, возвращает requiresPassword=true и tempAuthToken.' }) @ApiBody({ type: PasswordlessVerifyDto }) - verifyOtp(@Body() dto: PasswordlessVerifyDto) { - return this.core.auth.VerifyOtp(dto); + verifyOtp(@Body() dto: PasswordlessVerifyDto, @Req() req: Request) { + return this.core.auth.VerifyOtp(enrichAuthClientMeta(req, dto)); } @Post('login/password') @ApiOperation({ summary: 'Войти по паролю', description: 'Identifier-first парольный шаг. Принимает login+password, либо tempAuthToken+password для совместимости, затем выдает JWT.' }) @ApiBody({ type: PasswordLoginDto }) - loginWithPassword(@Body() dto: PasswordLoginDto) { - return this.core.auth.LoginWithPassword(dto); + loginWithPassword(@Body() dto: PasswordLoginDto, @Req() req: Request) { + return this.core.auth.LoginWithPassword(enrichAuthClientMeta(req, dto)); } @Post('ldap/login') @ApiOperation({ summary: 'Войти через LDAP/LDAPS', description: 'Аутентификация через корпоративный LDAP-сервер. Требует включённой настройки LDAP_ENABLED.' }) @ApiBody({ type: LdapLoginDto }) - loginWithLdap(@Body() dto: LdapLoginDto) { - return this.core.auth.LoginWithLdap(dto); + loginWithLdap(@Body() dto: LdapLoginDto, @Req() req: Request) { + return this.core.auth.LoginWithLdap(enrichAuthClientMeta(req, dto)); } @Post('pin/verify') @@ -75,15 +77,15 @@ export class AuthController { @Post('totp/begin') @ApiOperation({ summary: 'Начать вход по TOTP', description: 'Создаёт challenge для входа через приложение-аутентификатор вместо SMS/email OTP.' }) @ApiBody({ type: BeginTotpLoginDto }) - beginTotpLogin(@Body() dto: BeginTotpLoginDto) { - return this.core.auth.BeginTotpLogin(dto); + beginTotpLogin(@Body() dto: BeginTotpLoginDto, @Req() req: Request) { + return this.core.auth.BeginTotpLogin(enrichAuthClientMeta(req, dto)); } @Post('totp/verify') @ApiOperation({ summary: 'Подтвердить TOTP при входе', description: 'Завершает вход после проверки кода из Google Authenticator или аналога.' }) @ApiBody({ type: VerifyTotpLoginDto }) - verifyTotpLogin(@Body() dto: VerifyTotpLoginDto) { - return this.core.auth.VerifyTotpLogin(dto); + verifyTotpLogin(@Body() dto: VerifyTotpLoginDto, @Req() req: Request) { + return this.core.auth.VerifyTotpLogin(enrichAuthClientMeta(req, dto)); } @Post('refresh') diff --git a/apps/api-gateway/src/controllers/fedcm.controller.ts b/apps/api-gateway/src/controllers/fedcm.controller.ts index 64b415a..669ba12 100644 --- a/apps/api-gateway/src/controllers/fedcm.controller.ts +++ b/apps/api-gateway/src/controllers/fedcm.controller.ts @@ -194,15 +194,9 @@ export class FedcmController { ); } - @Post('session/sync') - @HttpCode(200) - @ApiOperation({ - summary: 'Синхронизировать FedCM cookie', - description: 'Устанавливает HttpOnly cookie для FedCM по Bearer access token (для уже авторизованных пользователей IdP).' - }) - async syncSession( - @Headers('authorization') authorization: string | undefined, - @Res({ passthrough: true }) res: Response + private async syncFedcmSessionFromAuthorization( + authorization: string | undefined, + res: Response ) { const payload = await verifyAccessToken(this.jwt, authorization); if (!payload.sessionId) { @@ -217,4 +211,30 @@ export class FedcmController { applyFedcmLoginStatus(res, true); return { synced: true }; } + + @Post('session/sync') + @HttpCode(200) + @ApiOperation({ + summary: 'Синхронизировать FedCM cookie', + description: 'Устанавливает HttpOnly cookie для FedCM по Bearer access token (для уже авторизованных пользователей IdP).' + }) + syncSessionPost( + @Headers('authorization') authorization: string | undefined, + @Res({ passthrough: true }) res: Response + ) { + return this.syncFedcmSessionFromAuthorization(authorization, res); + } + + @Get('session/sync') + @HttpCode(200) + @ApiOperation({ + summary: 'Синхронизировать FedCM cookie (GET)', + description: 'Тот же sync по Bearer token. GET нужен для совместимости с редиректами прокси и prefetch.' + }) + syncSessionGet( + @Headers('authorization') authorization: string | undefined, + @Res({ passthrough: true }) res: Response + ) { + return this.syncFedcmSessionFromAuthorization(authorization, res); + } } diff --git a/apps/api-gateway/src/controllers/security.controller.ts b/apps/api-gateway/src/controllers/security.controller.ts index 9e48f5b..7457d0e 100644 --- a/apps/api-gateway/src/controllers/security.controller.ts +++ b/apps/api-gateway/src/controllers/security.controller.ts @@ -27,7 +27,8 @@ export class SecurityController { @ApiOperation({ summary: 'Активные сессии', description: 'Возвращает ACTIVE и LOCKED сессии пользователя для управления устройствами.' }) @ApiParam({ name: 'userId', description: 'ID пользователя' }) @ApiResponse({ status: 200, description: 'Список активных сессий получен' }) - listSessions(@Param('userId') userId: string) { + async listSessions(@Param('userId') userId: string, @Headers('authorization') authorization?: string) { + await resolveAuthorizedPayload(this.jwt, this.core, authorization); return this.core.security.ListActiveSessions({ userId }); } @@ -35,8 +36,10 @@ export class SecurityController { @ApiOperation({ summary: 'История входов', description: 'Показывает последние попытки входа и причины отказов.' }) @ApiParam({ name: 'userId', description: 'ID пользователя' }) @ApiResponse({ status: 200, description: 'История входов получена' }) - listHistory(@Param('userId') userId: string) { - return this.core.security.ListSignInHistory({ userId }); + listHistory(@Param('userId') userId: string, @Headers('authorization') authorization?: string) { + return resolveAuthorizedPayload(this.jwt, this.core, authorization).then(() => + this.core.security.ListSignInHistory({ userId }) + ); } @Get('users/:userId/totp/status') diff --git a/apps/frontend/app/auth/login/page.tsx b/apps/frontend/app/auth/login/page.tsx index 7392da6..9fe23c5 100644 --- a/apps/frontend/app/auth/login/page.tsx +++ b/apps/frontend/app/auth/login/page.tsx @@ -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); diff --git a/apps/frontend/app/family/page.tsx b/apps/frontend/app/family/page.tsx index fe20408..3effc11 100644 --- a/apps/frontend/app/family/page.tsx +++ b/apps/frontend/app/family/page.tsx @@ -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([]); 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('/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 (
Загрузка...
diff --git a/apps/frontend/app/security/page.tsx b/apps/frontend/app/security/page.tsx index ef27619..1249081 100644 --- a/apps/frontend/app/security/page.tsx +++ b/apps/frontend/app/security/page.tsx @@ -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([]); + const [sessions, setSessions] = useState([]); const [history, setHistory] = useState([]); + const [currentSessionId, setCurrentSessionId] = useState(null); const [isSecurityLoading, setIsSecurityLoading] = useState(true); const [isRevoking, setIsRevoking] = useState(false); const [dialogMode, setDialogMode] = useState(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(`/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 (

Безопасность

-

Устройства

+

Устройства и сессии

На них вы вошли в ID и получаете уведомления безопасности от сервисов

+

Активные сессии

+ {isSecurityLoading ? ( +
Загружаем сессии...
+ ) : sessions.length ? ( + sessions.map((session) => { + const Icon = deviceIcons[session.deviceType ?? 'OTHER'] ?? Laptop; + const isCurrent = session.id === currentSessionId; + return ( +
+
+ +
+
+
+ {sessionDeviceLabel(session)} + {isCurrent ? ( + + Текущая + + ) : null} +
+ {sessionMetaLine(session)} +
+ {!isCurrent ? ( + + ) : null} +
+ ); + }) + ) : ( +
Активных сессий пока нет
+ )} +
+ +
+

Другие устройства

{isSecurityLoading ? (
Загружаем устройства...
) : devices.length ? ( @@ -443,10 +517,17 @@ export default function SecurityPage() {
Загружаем историю входов...
) : history.length ? ( history.map((item) => ( -
- - {item.success ? 'Успешный вход' : item.reason ?? 'Неудачная попытка входа'} - {formatDate(item.createdAt)} +
+ +
+

{item.success ? 'Успешный вход' : item.reason ?? 'Неудачная попытка входа'}

+

+ {[item.deviceName, item.ipAddress ?? 'IP не сохранён', formatUserAgentLabel(item.userAgent)] + .filter(Boolean) + .join(' · ')} +

+
+ {formatDate(item.createdAt)}
)) ) : ( diff --git a/apps/frontend/components/chat/chat-image-lightbox.tsx b/apps/frontend/components/chat/chat-image-lightbox.tsx index ca10664..a09e1c2 100644 --- a/apps/frontend/components/chat/chat-image-lightbox.tsx +++ b/apps/frontend/components/chat/chat-image-lightbox.tsx @@ -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 ) : ( -
Загрузка изображения...
+ )}
@@ -137,7 +138,9 @@ export function ChatImageLightbox({ {thumb?.blobUrl ? ( // eslint-disable-next-line @next/next/no-img-element - ) : null} + ) : ( + + )} ); })} diff --git a/apps/frontend/components/chat/chat-media-album-grid.tsx b/apps/frontend/components/chat/chat-media-album-grid.tsx index 4f7aeba..59f945d 100644 --- a/apps/frontend/components/chat/chat-media-album-grid.tsx +++ b/apps/frontend/components/chat/chat-media-album-grid.tsx @@ -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; + mediaUrls: Record; 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 (