655 lines
25 KiB
TypeScript
655 lines
25 KiB
TypeScript
'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;
|
||
}
|
||
|
||
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);
|
||
// прочитанность отмечается по мере попадания сообщений в зону видимости (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 (
|
||
<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"
|
||
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 ? 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>
|
||
);
|
||
}
|
||
|