'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);
// Молниеносный скролл вниз (мгновенно, без анимации)
requestAnimationFrame(() => {
const el = listRef.current;
if (el) el.scrollTo({ top: el.scrollHeight, behavior: 'auto' });
});
} finally {
setLoading(false);
}
})();
}, [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();
}
}}
/>
);
}