uchill/front_material/components/chat/ChatWindow.tsx

663 lines
24 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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: <CheckCircleRoundedIcon sx={{ fontSize: 18 }} />, label: 'ДЗ проверено' };
}
if (/(сдано|сдал)\s|дз сдано|домашнее задание сдано/.test(lower)) {
return { icon: <SendRoundedIcon sx={{ fontSize: 18 }} />, label: 'ДЗ сдано' };
}
if (/просрочено|просрочен/.test(lower)) {
return { icon: <WarningAmberRoundedIcon sx={{ fontSize: 18 }} />, label: 'Просрочено' };
}
if (/напоминание|дедлайн|напоминание о дедлайне/.test(lower)) {
return { icon: <ScheduleRoundedIcon sx={{ fontSize: 18 }} />, label: 'Напоминание' };
}
if (/удалено|удален|удалил|сообщение удалено/.test(lower)) {
return { icon: <DeleteRoundedIcon sx={{ fontSize: 18 }} />, label: 'Удалено' };
}
if (/занятие началось|занятие завершено|урок начался|урок завершен|перенесен|отменен/.test(lower)) {
return { icon: <EventNoteRoundedIcon sx={{ fontSize: 18 }} />, label: 'Занятие' };
}
if (/новое домашнее|назначено.*домашнее|домашнее задание|^дз\s|дз назначено/.test(lower)) {
return { icon: <AssignmentRoundedIcon sx={{ fontSize: 18 }} />, label: 'Домашнее задание' };
}
if (/уведомление|напоминани/.test(lower) || /^🔔/.test(raw)) {
return { icon: <NotificationsActiveRoundedIcon sx={{ fontSize: 18 }} />, label: 'Уведомление' };
}
return { icon: <InfoOutlinedIcon sx={{ fontSize: 18 }} />, label: 'Системное сообщение' };
}
export function ChatWindow({
chat,
currentUserId,
onBack,
onMessagesMarkedAsRead,
}: {
chat: Chat | null;
currentUserId: number | null;
onBack?: () => void;
/** Вызывается после того, как часть сообщений отмечена прочитанными (для обновления счётчика в списке чатов) */
onMessagesMarkedAsRead?: () => void;
}) {
const [messages, setMessages] = React.useState<Message[]>([]);
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<HTMLDivElement>(null);
const lastWheelUpAtRef = React.useRef<number>(0);
const markedReadRef = React.useRef<Set<string>>(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 (
<Box
className="ios-glass-panel"
sx={{
flex: 1,
borderRadius: '20px',
p: 3,
background: 'var(--ios26-glass)',
border: '1px solid var(--ios26-glass-border)',
backdropFilter: 'var(--ios26-blur)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--md-sys-color-on-surface-variant)',
}}
>
Выберите чат из списка
</Box>
);
}
return (
<Box
className="ios-glass-panel"
data-tour="chat-window"
sx={{
flex: 1,
borderRadius: '20px',
p: 2,
background: 'var(--ios26-glass)',
border: '1px solid var(--ios26-glass-border)',
backdropFilter: 'var(--ios26-blur)',
display: 'flex',
flexDirection: 'column',
minHeight: 0,
}}
>
{/* Header */}
<Box sx={{ px: 1, py: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25, minWidth: 0 }}>
{onBack && (
<IconButton
onClick={onBack}
sx={{ mr: 0.5, color: 'var(--md-sys-color-on-surface)' }}
aria-label="Назад"
>
<span className="material-symbols-outlined" style={{ fontSize: 24 }}>
arrow_back
</span>
</IconButton>
)}
<Avatar
src={(chat as any).avatar_url || undefined}
alt={chat.participant_name || 'Аватар'}
sx={{
width: 40,
height: 40,
bgcolor: 'rgba(116, 68, 253, 0.18)',
color: 'var(--md-sys-color-primary)',
fontWeight: 800,
}}
>
{(chat.participant_name || 'Ч')
.trim()
.split(/\s+/)
.slice(0, 2)
.map((p) => p[0])
.join('')
.toUpperCase()}
</Avatar>
<Box sx={{ minWidth: 0 }}>
<Typography
sx={{
fontWeight: 800,
fontSize: 16,
color: 'var(--md-sys-color-on-surface)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{chat.participant_name || 'Чат'}
</Typography>
<Typography sx={{ fontSize: 12, color: 'var(--md-sys-color-on-surface-variant)' }}>
{presence?.is_online
? 'Онлайн'
: formatLastSeen(presence?.last_activity ?? null) || 'Оффлайн'}
</Typography>
</Box>
</Box>
</Box>
{/* Messages */}
<Box
ref={listRef}
sx={{
flex: 1,
minHeight: 0,
overflowY: 'auto',
px: 1,
py: 1,
display: 'flex',
flexDirection: 'column',
gap: 1,
}}
onWheel={(e) => {
// 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 && (
<Typography sx={{ color: 'var(--md-sys-color-on-surface-variant)', textAlign: 'center', fontSize: 12 }}>
Загрузка
</Typography>
)}
{loading ? (
<Typography sx={{ color: 'var(--md-sys-color-on-surface-variant)', textAlign: 'center', mt: 2 }}>
Загрузка
</Typography>
) : (
(() => {
const out: React.ReactNode[] = [];
let prevDay = '';
const seen = new Set<string>();
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(
<Box
key={`day-${day}`}
sx={{
alignSelf: 'center',
px: 1.5,
py: 0.5,
borderRadius: 999,
backgroundColor: 'rgba(255,255,255,0.7)',
border: '1px solid rgba(0,0,0,0.06)',
color: 'var(--md-sys-color-on-surface-variant)',
fontSize: 12,
fontWeight: 700,
mb: 0.5,
}}
>
{formatDayHeader(created)}
</Box>
);
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(
<Box
key={msgKey}
data-message-uuid={msgUuid ?? undefined}
data-is-mine={isMine}
sx={{
alignSelf: isSystem ? 'center' : isMine ? 'flex-end' : 'flex-start',
maxWidth: isSystem ? '85%' : '75%',
px: 1.25,
py: 0.75,
borderRadius: 2,
backgroundColor: isSystem
? 'rgb(245 242 253)'
: isMine
? 'var(--md-sys-color-primary)'
: 'rgba(255,255,255,0.75)',
color: isSystem
? 'var(--md-sys-color-on-surface)'
: isMine
? 'var(--md-sys-color-on-primary)'
: 'var(--md-sys-color-on-surface)',
border: isSystem ? '1px dashed rgba(116, 68, 253, 0.25)' : isMine ? 'none' : '1px solid rgba(0,0,0,0.06)',
}}
>
{isSystem && sysTheme && (
<>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
mb: 0.25,
color: 'var(--md-sys-color-primary)',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center' }}>{sysTheme.icon}</Box>
<Typography
sx={{
fontSize: 11,
fontWeight: 800,
letterSpacing: '0.06em',
color: 'inherit',
textTransform: 'uppercase',
}}
>
{sysTheme.label}
</Typography>
</Box>
<Typography sx={{ fontSize: 13, whiteSpace: 'pre-wrap' }}>
{sysDisplayContent || '—'}
</Typography>
</>
)}
{!isSystem && (
<Typography sx={{ fontSize: 13, whiteSpace: 'pre-wrap' }}>{msgContent}</Typography>
)}
<Typography sx={{ fontSize: 11, opacity: 0.7, mt: 0.25, textAlign: 'right' }}>
{formatRelativeStamp((m as any).created_at)}
</Typography>
</Box>
);
});
return out;
})()
)}
</Box>
{/* Input */}
<Box
sx={{
pt: 1.25,
borderTop: '1px solid rgba(0,0,0,0.06)',
display: 'flex',
gap: 1,
alignItems: 'center',
}}
>
<TextField
value={text}
onChange={(e) => 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();
}
}}
/>
<IconButton
onClick={handleSend}
sx={{
backgroundColor: 'var(--md-sys-color-primary)',
color: 'var(--md-sys-color-on-primary)',
'&:hover': { backgroundColor: 'color-mix(in srgb, var(--md-sys-color-primary) 88%, #000 12%)' },
}}
>
<SendRoundedIcon />
</IconButton>
</Box>
</Box>
);
}