feat: оптимизация загрузки уведомлений и счетчиков чатов

Frontend изменения:
- Оптимизирована загрузка уведомлений: только 50 unread для badges
- Упрощены проверки is_read в AppShell и NotificationCenter
- Разделены useNotifications (для badges) и notifications page (для списка)
- Добавлены визуальные индикаторы в список чатов (badges, pin, notifications)
- Улучшена сортировка: закрепленные чаты показываются первыми

Связано с backend оптимизацией denormalized unread_count
This commit is contained in:
lizerk 2026-03-10 18:09:43 +03:00
parent 7dcc775b71
commit 8838b09737
6 changed files with 130 additions and 32 deletions

View File

@ -5,7 +5,7 @@ import { AppShell } from "../../components/AppShell";
import { apiClient } from "@/lib/api";
import { resolveMediaUrl } from "@/lib/url";
import type { Chat } from "@/types/api";
import { Search, MessageCircle } from "lucide-react";
import { Search, MessageCircle, Pin, BellOff } from "lucide-react";
import Link from "next/link";
import Image from "next/image";
import { useUser } from "@/contexts/UserContext";
@ -236,7 +236,17 @@ export default function MessagesPage() {
const filteredChats = useMemo(() => {
const q = search.trim().toLowerCase();
const sorted = [...chats].sort((a, b) => getChatSortTimestamp(b) - getChatSortTimestamp(a));
// Сортировка: закрепленные сверху, потом по времени последнего сообщения
const sorted = [...chats].sort((a, b) => {
// Сначала сортируем по закрепленности
const aPinned = a.is_pinned ? 1 : 0;
const bPinned = b.is_pinned ? 1 : 0;
if (aPinned !== bPinned) return bPinned - aPinned;
// Затем по времени последнего сообщения
return getChatSortTimestamp(b) - getChatSortTimestamp(a);
});
if (!q) return sorted;
@ -284,7 +294,11 @@ export default function MessagesPage() {
<Link
key={chat.id}
href={`/messages/${chat.id}`}
className="block w-full rounded-xl border border-transparent p-3 text-left transition hover:border-gray-200 hover:bg-gray-50"
className={`block w-full rounded-xl border p-3 text-left transition hover:border-gray-300 hover:bg-gray-50 ${
(chat.unread_count ?? 0) > 0
? "border-sky-100 bg-sky-50/30"
: "border-transparent"
}`}
>
<div className="flex items-start gap-3">
<div className="relative h-10 w-10">
@ -305,13 +319,30 @@ export default function MessagesPage() {
{isChatOnline(chat, user?.id) ? (
<span className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 rounded-full bg-sky-400 ring-2 ring-white" />
) : null}
{(chat.unread_count ?? 0) > 0 ? (
<span className="absolute -top-1 -right-1 z-10 flex h-5 min-w-[20px] items-center justify-center rounded-full bg-red-500 px-1.5 text-[10px] font-bold text-white ring-2 ring-white">
{chat.unread_count! > 99 ? '99+' : chat.unread_count}
</span>
) : null}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p className="truncate text-sm font-semibold text-gray-900">{chatTitle}</p>
<div className="flex items-center gap-1.5 min-w-0">
<p className={`truncate text-sm ${
(chat.unread_count ?? 0) > 0 ? "font-bold text-gray-900" : "font-semibold text-gray-900"
}`}>{chatTitle}</p>
{chat.is_pinned ? (
<Pin size={12} className="shrink-0 text-sky-600 fill-current" />
) : null}
{chat.notifications_enabled === false ? (
<BellOff size={12} className="shrink-0 text-gray-400" />
) : null}
</div>
<span className="shrink-0 text-xs text-gray-500">{formatTime(chat.last_message?.created_at)}</span>
</div>
<p className="mt-1 truncate text-xs text-gray-500">
<p className={`mt-1 truncate text-xs ${
(chat.unread_count ?? 0) > 0 ? "font-medium text-gray-700" : "text-gray-500"
}`}>
{chat.last_message?.content || "Нет сообщений"}
</p>
</div>

View File

@ -1,23 +1,80 @@
'use client';
import { AppShell } from '@/components/AppShell';
import { useNotifications } from '@/hooks/useApi';
import { apiClient } from '@/lib/api';
import { Bell, Check, CheckCheck, Filter, Search, Trash2, Settings } from 'lucide-react';
import { useState, useMemo } from 'react';
import { useState, useMemo, useEffect } from 'react';
import { formatDistanceToNow } from 'date-fns';
import { ru } from 'date-fns/locale/ru';
import { getVerbCategory, getVerbName } from '@/lib/verbTranslations';
import Link from 'next/link';
export default function NotificationsPage() {
const { notifications: notificationsData, loading, markAsRead, markAllAsRead, deleteNotification, deleteAllRead } = useNotifications();
const notifications = Array.isArray(notificationsData) ? notificationsData : [];
// Локальное состояние для ВСЕХ уведомлений (не только непрочитанных)
const [notifications, setNotifications] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [filterRead, setFilterRead] = useState<'all' | 'unread' | 'read'>('all');
const [filterCategory, setFilterCategory] = useState<string>('');
const [filtersOpen, setFiltersOpen] = useState(false);
// Загрузка всех уведомлений (100 последних)
useEffect(() => {
async function fetchAllNotifications() {
try {
const data = await apiClient.getNotifications({ page_size: 100 });
const notifs = data.notifications || data.results || data;
setNotifications(Array.isArray(notifs) ? notifs : []);
} catch (err) {
console.error('Ошибка загрузки уведомлений:', err);
} finally {
setLoading(false);
}
}
fetchAllNotifications();
}, []);
// Функции для операций с уведомлениями
const markAsRead = async (id: number) => {
try {
await apiClient.markNotificationAsRead(id);
setNotifications(prev =>
prev.map(n => (n.id === id ? { ...n, is_read: true } : n))
);
} catch (err) {
console.error('Ошибка отметки уведомления:', err);
}
};
const markAllAsRead = async () => {
try {
await apiClient.markAllNotificationsAsRead();
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })));
} catch (err) {
console.error('Ошибка отметки всех уведомлений:', err);
}
};
const deleteNotification = async (id: number) => {
try {
await apiClient.deleteNotification(id);
setNotifications(prev => prev.filter(n => n.id !== id));
} catch (err) {
console.error('Ошибка удаления уведомления:', err);
}
};
const deleteAllRead = async () => {
try {
await apiClient.deleteAllReadNotifications();
setNotifications(prev => prev.filter(n => !n.is_read));
} catch (err) {
console.error('Ошибка удаления прочитанных:', err);
}
};
// Уникальные категории
const categories = useMemo(() => {
const cats = new Set<string>();

View File

@ -250,7 +250,8 @@ function LeftNavContent({ onNavigate }: LeftNavContentProps) {
const counts: Record<string, number> = {};
notifications.forEach((n: any) => {
if (!n.category || n.is_read) return; // Только непрочитанные
// Пропускаем прочитанные
if (!n.category || n.is_read) return;
const category = getVerbCategory(n.category);
counts[category] = (counts[category] || 0) + 1;
});

View File

@ -53,10 +53,7 @@ export function NotificationCenter({ variant = 'default', isOpen: externalIsOpen
}, [isOpen]);
const handleNotificationClick = async (notification: any) => {
// v2 API использует unread вместо is_read (инвертированное)
const isUnread = notification.unread ?? !notification.is_read;
if (isUnread) {
if (!notification.is_read) {
await markAsRead(notification.id);
}
@ -135,8 +132,7 @@ export function NotificationCenter({ variant = 'default', isOpen: externalIsOpen
) : (
<ul>
{notifications.slice(0, 20).map((notification) => {
// v2 API: unread вместо is_read (инвертированное)
const isUnread = notification.unread ?? !notification.is_read;
const isUnread = !notification.is_read;
// v2 API: timestamp вместо created_at
const timestamp = notification.timestamp || notification.created_at;
// v2 API: verb + description вместо title + message
@ -212,10 +208,7 @@ export function NotificationPanel({ onClose }: { onClose?: () => void }) {
const notifications = Array.isArray(notificationsData) ? notificationsData : [];
const handleNotificationClick = async (notification: any) => {
// v2 API использует unread вместо is_read (инвертированное)
const isUnread = notification.unread ?? !notification.is_read;
if (isUnread) {
if (!notification.is_read) {
await markAsRead(notification.id);
}
if (notification.action_url) {
@ -262,8 +255,7 @@ export function NotificationPanel({ onClose }: { onClose?: () => void }) {
) : (
<ul>
{notifications.slice(0, 20).map((notification) => {
// v2 API: unread вместо is_read (инвертированное)
const isUnread = notification.unread ?? !notification.is_read;
const isUnread = !notification.is_read;
// v2 API: timestamp вместо created_at
const timestamp = notification.timestamp || notification.created_at;
// v2 API: verb + description вместо title + message

View File

@ -179,13 +179,18 @@ export function useNotifications() {
useEffect(() => {
async function fetchNotifications() {
try {
const data = await apiClient.getNotifications();
// Бэкенд возвращает { notifications: [...] }
// Для бейджей загружаем только непрочитанные (первые 50)
const data = await apiClient.getNotifications({
page_size: 50,
unread_only: true
});
// Бэкенд возвращает { notifications: [...], unread_count: N }
const notifs = data.notifications || data.results || data;
// Убеждаемся что notifs это массив
const notificationsArray = Array.isArray(notifs) ? notifs : [];
setNotifications(notificationsArray);
setUnreadCount(notificationsArray.filter((n: any) => !n.is_read).length);
// Используем unread_count из API или считаем сами
setUnreadCount(data.unread_count ?? notificationsArray.length);
} catch (err) {
setError(err as Error);
setNotifications([]); // В случае ошибки устанавливаем пустой массив
@ -256,7 +261,7 @@ export function useNotifications() {
});
// Обновляем счетчик если непрочитанное
if (data.notification.unread) {
if (!data.notification.is_read) {
setUnreadCount(prev => prev + 1);
}
}
@ -311,7 +316,7 @@ export function useNotifications() {
try {
await apiClient.markNotificationAsRead(id);
setNotifications(prev =>
prev.map(n => (n.id === id ? { ...n, is_read: true, unread: false } : n))
prev.map(n => (n.id === id ? { ...n, is_read: true } : n))
);
setUnreadCount(prev => Math.max(0, prev - 1));
} catch (err) {
@ -322,7 +327,7 @@ export function useNotifications() {
const markAllAsRead = async () => {
try {
await apiClient.markAllNotificationsAsRead();
setNotifications(prev => prev.map(n => ({ ...n, is_read: true, unread: false })));
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })));
setUnreadCount(0);
} catch (err) {
console.error('Failed to mark all notifications as read:', err);
@ -335,7 +340,7 @@ export function useNotifications() {
setNotifications(prev => prev.filter(n => n.id !== id));
// Если удаленное было непрочитанным, уменьшаем счетчик
const deletedNotif = notifications.find(n => n.id === id);
if (deletedNotif && (deletedNotif.unread ?? !deletedNotif.is_read)) {
if (deletedNotif && !deletedNotif.is_read) {
setUnreadCount(prev => Math.max(0, prev - 1));
}
} catch (err) {
@ -346,7 +351,7 @@ export function useNotifications() {
const deleteAllRead = async () => {
try {
const result = await apiClient.deleteAllReadNotifications();
setNotifications(prev => prev.filter(n => n.unread ?? !n.is_read));
setNotifications(prev => prev.filter(n => !n.is_read));
return result.count;
} catch (err) {
console.error('Failed to delete all read notifications:', err);

View File

@ -787,8 +787,20 @@ class ApiClient {
}
// Уведомления
async getNotifications(): Promise<any> {
return this.request('/api/v1/notifications/');
async getNotifications(params?: { page?: number; page_size?: number; unread_only?: boolean }): Promise<any> {
const queryParams = new URLSearchParams();
if (params?.page) queryParams.append('page', String(params.page));
if (params?.page_size) queryParams.append('page_size', String(params.page_size));
if (params?.unread_only) queryParams.append('unread_only', 'true');
const url = queryParams.toString()
? `/api/v1/notifications/?${queryParams.toString()}`
: '/api/v1/notifications/';
return this.request(url);
}
async getUnreadNotificationsCount(): Promise<{ count: number }> {
return this.request('/api/v1/notifications/count/');
}
async markNotificationAsRead(id: number): Promise<void> {