'use client'; import React from 'react'; import { Avatar, Box, IconButton, TextField, Typography } from '@mui/material'; import SendRoundedIcon from '@mui/icons-material/SendRounded'; import AssignmentRoundedIcon from '@mui/icons-material/AssignmentRounded'; import CheckCircleRoundedIcon from '@mui/icons-material/CheckCircleRounded'; import DeleteRoundedIcon from '@mui/icons-material/DeleteRounded'; import ScheduleRoundedIcon from '@mui/icons-material/ScheduleRounded'; import EventNoteRoundedIcon from '@mui/icons-material/EventNoteRounded'; import NotificationsActiveRoundedIcon from '@mui/icons-material/NotificationsActiveRounded'; import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import type { Chat, Message } from '@/api/chat'; import { getMessages, getChatMessagesByUuid, sendMessage, markMessagesAsRead } from '@/api/chat'; import { useChatWebSocket } from '@/hooks/useChatWebSocket'; import { getUserStatus, subscribeToUserStatus } from '@/hooks/usePresenceWebSocket'; function formatTime(ts?: string) { try { if (!ts) return ''; const d = new Date(ts); if (Number.isNaN(d.getTime())) return ''; return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); } catch { return ''; } } function formatRelativeStamp(ts?: string) { if (!ts) return ''; const d = new Date(ts); if (Number.isNaN(d.getTime())) return ''; const now = new Date(); const diffMs = now.getTime() - d.getTime(); if (diffMs < 0) return formatTime(ts); const diffMin = Math.floor(diffMs / 60000); const diffHr = Math.floor(diffMs / 3600000); const pluralRu = (n: number, one: string, few: string, many: string) => { const mod10 = n % 10; const mod100 = n % 100; if (mod10 === 1 && mod100 !== 11) return one; if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return few; return many; }; if (diffMin < 1) return 'только что'; if (diffMin < 60) return `${diffMin} ${pluralRu(diffMin, 'минуту', 'минуты', 'минут')} назад`; if (diffHr < 6) { if (diffHr === 1) return 'час назад'; return `${diffHr} ${pluralRu(diffHr, 'час', 'часа', 'часов')} назад`; } // больше 6 часов — показываем HH:mm return formatTime(ts); } function dateKey(ts?: string) { if (!ts) return ''; const d = new Date(ts); if (Number.isNaN(d.getTime())) return ''; const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); return `${y}-${m}-${day}`; } function formatDayHeader(ts?: string) { if (!ts) return ''; const d = new Date(ts); if (Number.isNaN(d.getTime())) return ''; const now = new Date(); const todayKey = dateKey(now.toISOString()); const yKey = dateKey(new Date(now.getTime() - 24 * 3600000).toISOString()); const k = dateKey(ts); if (k === todayKey) return 'Сегодня'; if (k === yKey) return 'Вчера'; const dd = String(d.getDate()).padStart(2, '0'); const mm = String(d.getMonth() + 1).padStart(2, '0'); const yyyy = String(d.getFullYear()); return `${dd}.${mm}.${yyyy}`; } function formatLastSeen(iso: string | null) { if (!iso) return null; try { const d = new Date(iso); const now = new Date(); const sameYear = d.getFullYear() === now.getFullYear(); const sameDay = d.toDateString() === now.toDateString(); if (sameDay) { return `Был(а) в сети сегодня в ${d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}`; } const datePart = d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', ...(sameYear ? {} : { year: 'numeric' }), }); const timePart = d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); return `Был(а) в сети ${datePart} в ${timePart}`; } catch { return null; } } const SYSTEM_EMOJI_PREFIX = /^[\s🔔📝🗑️🗑✅📤⚠️⏰📅📚]+/; function stripLeadingEmojis(s: string): string { const t = (s || '').trim(); return t.replace(SYSTEM_EMOJI_PREFIX, '').replace(/^[-–—•\s]+/, '').trim() || t; } /** Убирает HTML-теги из строки (чтобы в чате не отображались теги в уведомлениях). */ function stripHtml(s: string): string { if (typeof s !== 'string') return ''; return s.replace(/<[^>]*>/g, '').trim(); } type SystemTheme = { icon: React.ReactNode; label: string; }; function getSystemMessageTheme(content: string): SystemTheme { const raw = (content || '').trim(); const lower = raw.toLowerCase(); if (/проверено|проверен/.test(lower) && /домашнее|дз|задани/.test(lower)) { return { icon: , label: 'ДЗ проверено' }; } if (/(сдано|сдал)\s|дз сдано|домашнее задание сдано/.test(lower)) { return { icon: , label: 'ДЗ сдано' }; } if (/просрочено|просрочен/.test(lower)) { return { icon: , label: 'Просрочено' }; } if (/напоминание|дедлайн|напоминание о дедлайне/.test(lower)) { return { icon: , label: 'Напоминание' }; } if (/удалено|удален|удалил|сообщение удалено/.test(lower)) { return { icon: , label: 'Удалено' }; } if (/занятие началось|занятие завершено|урок начался|урок завершен|перенесен|отменен/.test(lower)) { return { icon: , label: 'Занятие' }; } if (/новое домашнее|назначено.*домашнее|домашнее задание|^дз\s|дз назначено/.test(lower)) { return { icon: , label: 'Домашнее задание' }; } if (/уведомление|напоминани/.test(lower) || /^🔔/.test(raw)) { return { icon: , label: 'Уведомление' }; } return { icon: , label: 'Системное сообщение' }; } export function ChatWindow({ chat, currentUserId, onBack, onMessagesMarkedAsRead, }: { chat: Chat | null; currentUserId: number | null; onBack?: () => void; /** Вызывается после того, как часть сообщений отмечена прочитанными (для обновления счётчика в списке чатов) */ onMessagesMarkedAsRead?: () => void; }) { const [messages, setMessages] = React.useState([]); const [loading, setLoading] = React.useState(false); const [loadingMore, setLoadingMore] = React.useState(false); const [page, setPage] = React.useState(1); const [hasMore, setHasMore] = React.useState(false); const [text, setText] = React.useState(''); const listRef = React.useRef(null); const lastWheelUpAtRef = React.useRef(0); const markedReadRef = React.useRef>(new Set()); const lastSentRef = React.useRef<{ id: number | string; uuid?: string } | null>(null); const chatUuid = (chat as any)?.uuid || null; const otherUserId = (chat as any)?.other_user_id ?? null; const [presence, setPresence] = React.useState<{ is_online: boolean; last_activity: string | null } | null>(null); // initial from chat list + realtime updates React.useEffect(() => { if (!chat) return; setPresence({ is_online: !!(chat as any).other_is_online, last_activity: (chat as any).other_last_activity ?? null, }); }, [chat]); // Сброс при смене чата React.useEffect(() => { markedReadRef.current = new Set(); lastSentRef.current = null; }, [chatUuid]); React.useEffect(() => { if (!otherUserId) return; const existing = getUserStatus(otherUserId); if (existing) setPresence({ is_online: existing.is_online, last_activity: existing.last_activity }); const unsubscribe = subscribeToUserStatus((s) => { if (s.user_id === otherUserId) setPresence({ is_online: s.is_online, last_activity: s.last_activity }); }); return () => { unsubscribe?.(); }; }, [otherUserId]); useChatWebSocket({ chatUuid, enabled: !!chatUuid, onMessage: (m) => { const chatId = chat?.id != null ? Number(chat.id) : null; const msgChatId = m.chat != null ? Number(m.chat) : null; if (chatId == null || msgChatId !== chatId) return; const mid = (m as any).id; const muuid = (m as any).uuid; const sent = lastSentRef.current; if (sent && (String(mid) === String(sent.id) || (muuid != null && sent.uuid != null && String(muuid) === String(sent.uuid)))) { lastSentRef.current = null; return; } setMessages((prev) => { const isDuplicate = prev.some((x) => { const sameId = mid != null && x.id != null && String(x.id) === String(mid); const sameUuid = muuid != null && (x as any).uuid != null && String((x as any).uuid) === String(muuid); return sameId || sameUuid; }); if (isDuplicate) return prev; return [...prev, m]; }); }, }); React.useEffect(() => { if (!chat) return; setLoading(true); setLoadingMore(false); setPage(1); setHasMore(false); (async () => { try { const pageSize = 30; const resp = chatUuid ? await getChatMessagesByUuid(chatUuid, { page: 1, page_size: pageSize }) : await getMessages(chat.id, { page: 1, page_size: pageSize }); const initial = (resp.results || []) as Message[]; // сортируем по времени на всякий случай const sorted = [...initial].sort((a: any, b: any) => { const ta = a?.created_at ? new Date(a.created_at).getTime() : 0; const tb = b?.created_at ? new Date(b.created_at).getTime() : 0; return ta - tb; }); setMessages(sorted); setHasMore(!!(resp as any).next || ((resp as any).count ?? 0) > sorted.length); // прочитанность отмечается по мере попадания сообщений в зону видимости (IntersectionObserver) } finally { setLoading(false); // scroll down setTimeout(() => { listRef.current?.scrollTo({ top: listRef.current.scrollHeight, behavior: 'smooth' }); }, 50); } })(); }, [chat?.id, chatUuid]); // Отмечаем сообщения прочитанными, когда они попадают в зону скролла React.useEffect(() => { if (!chatUuid || !listRef.current || messages.length === 0) return; const container = listRef.current; const observer = new IntersectionObserver( (entries) => { const toMark: string[] = []; for (const e of entries) { if (!e.isIntersecting) continue; const uuid = (e.target as HTMLElement).getAttribute('data-message-uuid'); const isMine = (e.target as HTMLElement).getAttribute('data-is-mine') === 'true'; if (uuid && !isMine && !markedReadRef.current.has(uuid)) { toMark.push(uuid); markedReadRef.current.add(uuid); } } if (toMark.length > 0) { markMessagesAsRead(chatUuid, toMark) .then(() => onMessagesMarkedAsRead?.()) .catch(() => {}); } }, { root: container, rootMargin: '0px', threshold: 0.5 } ); const nodes = container.querySelectorAll('[data-message-uuid]'); nodes.forEach((n) => observer.observe(n)); return () => observer.disconnect(); }, [chatUuid, messages, onMessagesMarkedAsRead]); const loadOlder = React.useCallback(async () => { if (!chat || loading || loadingMore || !hasMore) return; const container = listRef.current; if (!container) return; setLoadingMore(true); const prevScrollHeight = container.scrollHeight; const prevScrollTop = container.scrollTop; try { const nextPage = page + 1; const pageSize = 30; const resp = chatUuid ? await getChatMessagesByUuid(chatUuid, { page: nextPage, page_size: pageSize }) : await getMessages(chat.id, { page: nextPage, page_size: pageSize }); const batch = (resp.results || []) as Message[]; const sortedBatch = [...batch].sort((a: any, b: any) => { const ta = a?.created_at ? new Date(a.created_at).getTime() : 0; const tb = b?.created_at ? new Date(b.created_at).getTime() : 0; return ta - tb; }); setMessages((prev) => { const existingKeys = new Set(prev.map((m: any) => m?.uuid || m?.id)); const toAdd = sortedBatch.filter((m: any) => !existingKeys.has(m?.uuid || m?.id)); const merged = [...toAdd, ...prev]; merged.sort((a: any, b: any) => { const ta = a?.created_at ? new Date(a.created_at).getTime() : 0; const tb = b?.created_at ? new Date(b.created_at).getTime() : 0; return ta - tb; }); return merged; }); setPage(nextPage); setHasMore(!!(resp as any).next); } finally { // сохранить позицию прокрутки setTimeout(() => { const c = listRef.current; if (!c) return; const newScrollHeight = c.scrollHeight; c.scrollTop = prevScrollTop + (newScrollHeight - prevScrollHeight); }, 0); setLoadingMore(false); } }, [chat, chatUuid, hasMore, loading, loadingMore, page]); const handleSend = async () => { if (!chat) return; const content = text.trim(); if (!content) return; setText(''); try { const msg = await sendMessage(chat.id, content); lastSentRef.current = { id: msg.id, uuid: (msg as any).uuid }; const safeMsg = { ...msg, created_at: (msg as any).created_at || new Date().toISOString() } as Message; setMessages((prev) => [...prev, safeMsg]); requestAnimationFrame(() => { requestAnimationFrame(() => { const el = listRef.current; if (el) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }); }); }); } catch { // rollback setText(content); } }; if (!chat) { return ( Выберите чат из списка ); } return ( {/* Header */} {onBack && ( arrow_back )} {(chat.participant_name || 'Ч') .trim() .split(/\s+/) .slice(0, 2) .map((p) => p[0]) .join('') .toUpperCase()} {chat.participant_name || 'Чат'} {presence?.is_online ? 'Онлайн' : formatLastSeen(presence?.last_activity ?? null) || 'Оффлайн'} {/* Messages */} { // deltaY < 0 = прокрутка вверх if (e.deltaY < 0) lastWheelUpAtRef.current = Date.now(); }} onScroll={(e) => { const el = e.currentTarget; // Подгружаем только если: // - до верха осталось < 40px // - пользователь именно скроллит вверх (wheel up недавно) const nearTop = el.scrollTop < 40; const wheelUpRecently = Date.now() - lastWheelUpAtRef.current < 200; if (nearTop && wheelUpRecently) loadOlder(); }} > {loadingMore && ( Загрузка… )} {loading ? ( Загрузка… ) : ( (() => { const out: React.ReactNode[] = []; let prevDay = ''; const seen = new Set(); const uniqueMessages = messages.filter((m) => { const k = String((m as any).uuid ?? m.id ?? ''); if (!k || seen.has(k)) return false; seen.add(k); return true; }); uniqueMessages.forEach((m, idx) => { const created = (m as any).created_at as string | undefined; const day = dateKey(created); if (day && day !== prevDay) { out.push( {formatDayHeader(created)} ); prevDay = day; } const senderId = (m as any).sender_id ?? (typeof (m as any).sender === 'number' ? (m as any).sender : (m as any).sender?.id ?? null); const isMine = !!currentUserId && senderId === currentUserId; const isSystem = (m as any).message_type === 'system' || (typeof (m as any).sender === 'string' && (m as any).sender.toLowerCase() === 'system') || (!senderId && (m as any).sender_name === 'System'); const msgContent = (m as any).content || ''; const sysTheme = isSystem ? getSystemMessageTheme(msgContent) : null; const sysDisplayContent = isSystem ? stripHtml(stripLeadingEmojis(msgContent)) : ''; const msgUuid = (m as any).uuid ? String((m as any).uuid) : null; const msgKey = (m as any).uuid || m.id || `msg-${idx}`; out.push( {isSystem && sysTheme && ( <> {sysTheme.icon} {sysTheme.label} {sysDisplayContent || '—'} )} {!isSystem && ( {msgContent} )} {formatRelativeStamp((m as any).created_at)} ); }); return out; })() )} {/* Input */} setText(e.target.value)} placeholder="Сообщение…" fullWidth multiline minRows={1} maxRows={4} sx={{ '& .MuiInputBase-root': { borderRadius: 3, backgroundColor: 'rgba(255,255,255,0.85)', }, }} onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }} /> ); }