first commit
This commit is contained in:
233
apps/frontend/components/notifications/notification-bell.tsx
Normal file
233
apps/frontend/components/notifications/notification-bell.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Bell, Loader2, Trash2 } from 'lucide-react';
|
||||
import { useAuth } from '@/components/id/auth-provider';
|
||||
import { useToast } from '@/components/id/toast-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import {
|
||||
AppNotification,
|
||||
fetchNotifications,
|
||||
fetchUnreadNotificationCount,
|
||||
getApiErrorMessage,
|
||||
markAllNotificationsRead,
|
||||
markNotificationRead,
|
||||
respondFamilyInvite
|
||||
} from '@/lib/api';
|
||||
import { useRealtime } from '@/components/notifications/realtime-provider';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function formatTime(value: string) {
|
||||
return new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit', day: 'numeric', month: 'short' }).format(
|
||||
new Date(value)
|
||||
);
|
||||
}
|
||||
|
||||
export function NotificationBell() {
|
||||
const router = useRouter();
|
||||
const { token, isPinLocked } = useAuth();
|
||||
const { showToast } = useToast();
|
||||
const { unreadCount, setUnreadCount, subscribe } = useRealtime();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [items, setItems] = useState<AppNotification[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [respondingId, setRespondingId] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!token || isPinLocked) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const [list, count] = await Promise.all([fetchNotifications(token), fetchUnreadNotificationCount(token)]);
|
||||
setItems(list.notifications ?? []);
|
||||
setUnreadCount(count.count ?? 0);
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, 'Не удалось загрузить уведомления');
|
||||
if (message) showToast(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [isPinLocked, setUnreadCount, showToast, token]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
return subscribe((event) => {
|
||||
void load();
|
||||
if (event.type === 'chat_message' && event.title) {
|
||||
showToast(`${event.title}: ${event.message}`);
|
||||
}
|
||||
if (event.type === 'family_invite') {
|
||||
showToast(event.message || 'Новое приглашение в семью');
|
||||
}
|
||||
});
|
||||
}, [load, showToast, subscribe]);
|
||||
|
||||
async function openItem(item: AppNotification) {
|
||||
if (!token) return;
|
||||
await markNotificationRead(item.id, token);
|
||||
setUnreadCount((count) => Math.max(0, count - 1));
|
||||
setItems((current) => current.filter((entry) => entry.id !== item.id));
|
||||
let payload: Record<string, unknown> = {};
|
||||
try {
|
||||
payload = item.payloadJson ? JSON.parse(item.payloadJson) : {};
|
||||
} catch {
|
||||
payload = {};
|
||||
}
|
||||
if (payload.groupId) {
|
||||
setOpen(false);
|
||||
router.push(`/family/${payload.groupId}`);
|
||||
}
|
||||
}
|
||||
|
||||
function removeInviteNotification(inviteId: string) {
|
||||
setItems((current) => {
|
||||
let removedUnread = 0;
|
||||
const next = current.filter((item) => {
|
||||
if (item.type !== 'family_invite') return true;
|
||||
try {
|
||||
const payload = item.payloadJson ? JSON.parse(item.payloadJson) as { inviteId?: string } : {};
|
||||
if (payload.inviteId === inviteId) {
|
||||
if (!item.isRead) removedUnread += 1;
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
// keep item if payload is malformed
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (removedUnread > 0) {
|
||||
setUnreadCount((count) => Math.max(0, count - removedUnread));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function acceptInvite(inviteId: string) {
|
||||
if (!token) return;
|
||||
setRespondingId(inviteId);
|
||||
try {
|
||||
const invite = await respondFamilyInvite(inviteId, true, token);
|
||||
removeInviteNotification(inviteId);
|
||||
showToast('Вы присоединились к семье');
|
||||
setOpen(false);
|
||||
router.push(`/family/${invite.groupId}`);
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, 'Не удалось принять приглашение');
|
||||
if (message) showToast(message);
|
||||
} finally {
|
||||
setRespondingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function declineInvite(inviteId: string) {
|
||||
if (!token) return;
|
||||
setRespondingId(inviteId);
|
||||
try {
|
||||
await respondFamilyInvite(inviteId, false, token);
|
||||
removeInviteNotification(inviteId);
|
||||
showToast('Приглашение отклонено');
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, 'Не удалось отклонить приглашение');
|
||||
if (message) showToast(message);
|
||||
} finally {
|
||||
setRespondingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function markAllRead() {
|
||||
if (!token) return;
|
||||
await markAllNotificationsRead(token);
|
||||
setUnreadCount(0);
|
||||
setItems([]);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="relative flex h-11 w-11 items-center justify-center rounded-2xl bg-[#f4f5f8] transition hover:bg-[#eceef4]"
|
||||
aria-label="Уведомления"
|
||||
>
|
||||
<Bell className="h-5 w-5" />
|
||||
{unreadCount > 0 ? (
|
||||
<span className="absolute -right-1 -top-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-[#3390ec] px-1 text-[10px] font-semibold text-white">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-[360px] rounded-[24px] p-0">
|
||||
<div className="flex items-center justify-between border-b border-[#eceef4] px-4 py-3">
|
||||
<h3 className="font-semibold">Уведомления</h3>
|
||||
<Button variant="ghost" size="sm" className="h-8 gap-1 text-xs" onClick={() => void markAllRead()}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Очистить все
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-[420px] overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-10 text-sm text-[#667085]">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Загрузка...
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<p className="px-4 py-10 text-center text-sm text-[#667085]">Уведомлений пока нет</p>
|
||||
) : (
|
||||
items.map((item) => {
|
||||
let inviteId: string | undefined;
|
||||
try {
|
||||
inviteId = item.payloadJson ? (JSON.parse(item.payloadJson).inviteId as string) : undefined;
|
||||
} catch {
|
||||
inviteId = undefined;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(
|
||||
'border-b border-[#eceef4] px-4 py-3 transition hover:bg-[#fafbfd]',
|
||||
!item.isRead && 'bg-[#f0f8ff]/60'
|
||||
)}
|
||||
>
|
||||
<button type="button" className="w-full text-left" onClick={() => void openItem(item)}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="font-medium">{item.title}</p>
|
||||
<p className="mt-1 text-sm text-[#667085]">{item.message}</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-[11px] text-[#a8adbc]">{formatTime(item.createdAt)}</span>
|
||||
</div>
|
||||
</button>
|
||||
{item.type === 'family_invite' && inviteId ? (
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 flex-1 rounded-xl"
|
||||
disabled={respondingId === inviteId}
|
||||
onClick={() => void acceptInvite(inviteId!)}
|
||||
>
|
||||
{respondingId === inviteId ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Принять'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-8 flex-1 rounded-xl"
|
||||
onClick={() => void declineInvite(inviteId!)}
|
||||
>
|
||||
Отклонить
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
110
apps/frontend/components/notifications/realtime-provider.tsx
Normal file
110
apps/frontend/components/notifications/realtime-provider.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useAuth } from '@/components/id/auth-provider';
|
||||
import { WS_URL, type ChatMessage } from '@/lib/api';
|
||||
|
||||
export interface RealtimeEvent {
|
||||
userId?: string;
|
||||
type: string;
|
||||
title: string;
|
||||
message: string;
|
||||
payload?: Record<string, unknown> & {
|
||||
notificationId?: string;
|
||||
roomId?: string;
|
||||
message?: ChatMessage;
|
||||
inviteId?: string;
|
||||
groupId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
type Listener = (event: RealtimeEvent) => void;
|
||||
|
||||
interface RealtimeContextValue {
|
||||
unreadCount: number;
|
||||
setUnreadCount: React.Dispatch<React.SetStateAction<number>>;
|
||||
subscribe: (listener: Listener) => () => void;
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
const RealtimeContext = React.createContext<RealtimeContextValue | null>(null);
|
||||
|
||||
export function RealtimeProvider({ children }: { children: React.ReactNode }) {
|
||||
const { user, token, isPinLocked } = useAuth();
|
||||
const [unreadCount, setUnreadCount] = React.useState(0);
|
||||
const [connected, setConnected] = React.useState(false);
|
||||
const listenersRef = React.useRef(new Set<Listener>());
|
||||
const socketRef = React.useRef<WebSocket | null>(null);
|
||||
const reconnectTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const subscribe = React.useCallback((listener: Listener) => {
|
||||
listenersRef.current.add(listener);
|
||||
return () => listenersRef.current.delete(listener);
|
||||
}, []);
|
||||
|
||||
const emit = React.useCallback((event: RealtimeEvent) => {
|
||||
listenersRef.current.forEach((listener) => listener(event));
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!user || !token || isPinLocked) {
|
||||
socketRef.current?.close();
|
||||
socketRef.current = null;
|
||||
setConnected(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
function connect() {
|
||||
if (cancelled) return;
|
||||
const url = `${WS_URL}?token=${encodeURIComponent(token!)}`;
|
||||
const socket = new WebSocket(url);
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.onopen = () => setConnected(true);
|
||||
socket.onclose = () => {
|
||||
setConnected(false);
|
||||
if (!cancelled) {
|
||||
reconnectTimer.current = setTimeout(connect, 3000);
|
||||
}
|
||||
};
|
||||
socket.onerror = () => socket.close();
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data as string) as RealtimeEvent;
|
||||
emit(data);
|
||||
if (data.type === 'family_invite' || (data.type === 'chat_message' && data.payload?.notificationId)) {
|
||||
setUnreadCount((count) => count + 1);
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed payloads
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
||||
socketRef.current?.close();
|
||||
socketRef.current = null;
|
||||
setConnected(false);
|
||||
};
|
||||
}, [emit, isPinLocked, token, user]);
|
||||
|
||||
return (
|
||||
<RealtimeContext.Provider value={{ unreadCount, setUnreadCount, subscribe, connected }}>
|
||||
{children}
|
||||
</RealtimeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useRealtime() {
|
||||
const context = React.useContext(RealtimeContext);
|
||||
if (!context) {
|
||||
throw new Error('useRealtime должен использоваться внутри RealtimeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
Reference in New Issue
Block a user