// Max DM — Telegram MiniApp const { useState, useEffect, useRef, useCallback } = React; // ── Telegram WebApp ──────────────────────────────────────────────────────────── const tg = window.Telegram?.WebApp || {}; if (tg.expand) { tg.expand(); if (tg.enableClosingConfirmation) tg.enableClosingConfirmation(); } const API_BASE = window.location.origin + '/api/v1'; const IS_DEV = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; function getInitData() { return tg.initData || ''; } async function apiRequest(endpoint, options = {}) { const headers = { 'X-Init-Data': getInitData(), 'Content-Type': 'application/json', ...options.headers, }; const response = await fetch(API_BASE + endpoint, { ...options, headers }); if (!response.ok) throw new Error(`API error: ${response.status}`); return response.json(); } // ── Theme ────────────────────────────────────────────────────────────────────── function makeTheme(dark) { const accentHue = 25; const radius = 14; const accent = `oklch(0.7 0.18 ${accentHue})`; const accentSoft = dark ? `oklch(0.32 0.1 ${accentHue})` : `oklch(0.94 0.07 ${accentHue})`; const accentDeep = `oklch(0.55 0.17 ${accentHue})`; if (dark) return { accent, accentSoft, accentDeep, bg: 'oklch(0.18 0.008 60)', surface: 'oklch(0.22 0.008 60)', surfaceElev: 'oklch(0.26 0.008 60)', text: 'oklch(0.97 0.005 70)', textMuted: 'oklch(0.7 0.01 70)', textFaint: 'oklch(0.5 0.01 70)', stroke: 'oklch(0.3 0.008 60)', bubbleIn: 'oklch(0.27 0.008 60)', bubbleOut: accent, bubbleOutText: 'oklch(0.99 0.005 70)', radius, isDark: true, }; return { accent, accentSoft, accentDeep, bg: 'oklch(0.985 0.005 75)', surface: 'oklch(1 0 0)', surfaceElev: 'oklch(0.97 0.005 75)', text: 'oklch(0.18 0.008 60)', textMuted: 'oklch(0.45 0.01 70)', textFaint: 'oklch(0.65 0.01 70)', stroke: 'oklch(0.92 0.008 70)', bubbleIn: 'oklch(0.95 0.005 75)', bubbleOut: accent, bubbleOutText: 'oklch(0.99 0.005 70)', radius, isDark: false, }; } // ── Utilities ────────────────────────────────────────────────────────────────── function formatChatTime(ts) { const d = new Date(ts), now = new Date(); if (d.toDateString() === now.toDateString()) return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); const yest = new Date(now); yest.setDate(yest.getDate() - 1); if (d.toDateString() === yest.toDateString()) return 'вчера'; if ((now - d) / 86400000 < 7) return d.toLocaleDateString('ru-RU', { weekday: 'short' }); return d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' }); } function formatMsgTime(ts) { return new Date(ts).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); } function formatDayHeader(ts) { const d = new Date(ts), now = new Date(); if (d.toDateString() === now.toDateString()) return 'Сегодня'; const yest = new Date(now); yest.setDate(yest.getDate() - 1); if (d.toDateString() === yest.toDateString()) return 'Вчера'; return d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' }); } function hueFromKey(key) { const s = String(key); let h = 0; for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0; return Math.abs(h) % 360; } // ── Avatar ───────────────────────────────────────────────────────────────────── function Avatar({ name, id, size = 48, dark = true }) { const initial = (name || '?').charAt(0).toUpperCase(); const hue = hueFromKey(id ?? name ?? ''); return (
{initial}
); } // ── Loading screen ───────────────────────────────────────────────────────────── function LoadingScreen({ theme: T }) { return (
Подключаемся…
Загружаем ваши диалоги
); } // ── Empty screen ─────────────────────────────────────────────────────────────── function EmptyScreen({ theme: T }) { return (

Пока тихо

Когда вам напишут, диалоги появятся здесь

); } // ── Error screen ─────────────────────────────────────────────────────────────── function ErrorScreen({ theme: T, message, onRetry }) { return (

Что-то пошло не так

{message}

{onRetry && ( )}
); } // ── Chat list ────────────────────────────────────────────────────────────────── function ChatList({ theme: T, chats, onOpenChat }) { const [searchActive, setSearchActive] = useState(false); const [query, setQuery] = useState(''); const filtered = query ? chats.filter(c => c.chat_name.toLowerCase().includes(query.toLowerCase()) || (c.last_message_text || '').toLowerCase().includes(query.toLowerCase())) : chats; return (
{/* Header */}
{!searchActive ? (
Max DM

Чаты

) : (
setQuery(e.target.value)} placeholder="Имя или сообщение" style={{ flex: 1, border: 'none', outline: 'none', background: 'transparent', color: T.text, fontSize: 15, }} /> {query && ( )}
)}
{/* List */}
{filtered.length === 0 && query ? (
Ничего не нашлось по «{query}»
) : ( filtered.map(chat => ( onOpenChat(chat)} /> )) )}
); } function ChatRow({ chat, theme: T, onClick }) { const [pressed, setPressed] = useState(false); const ts = chat.last_message_time ? new Date(chat.last_message_time).getTime() : null; return (
setPressed(true)} onMouseUp={() => setPressed(false)} onMouseLeave={() => setPressed(false)} onTouchStart={() => setPressed(true)} onTouchEnd={() => setPressed(false)} style={{ display: 'flex', alignItems: 'center', gap: 13, padding: '12px 12px', margin: '0 4px', borderRadius: T.radius + 4, cursor: 'pointer', background: pressed ? T.surface : 'transparent', transition: 'background 0.12s', }} >
{chat.chat_name} {ts && ( {formatChatTime(ts)} )}
{chat.last_message_text || ''}
{chat.unread_count > 0 && (
{chat.unread_count}
)}
); } // ── Message grouping ─────────────────────────────────────────────────────────── function groupMessages(msgs) { const groups = []; let lastDay = null, lastSender = null, lastTs = null; for (const m of msgs) { const day = new Date(m.timestamp).toDateString(); const senderKey = m.is_outgoing ? '__me__' : String(m.sender_id); const breakGroup = day !== lastDay || senderKey !== lastSender || (lastTs !== null && m.timestamp - lastTs > 5 * 60 * 1000); if (day !== lastDay) { groups.push({ kind: 'day', ts: m.timestamp }); lastDay = day; } const last = groups[groups.length - 1]; if (breakGroup || !last || last.kind !== 'msgs' || last.sender !== senderKey) { groups.push({ kind: 'msgs', sender: senderKey, is_outgoing: m.is_outgoing, items: [] }); } groups[groups.length - 1].items.push(m); lastSender = senderKey; lastTs = m.timestamp; } return groups; } // ── Typing indicator ─────────────────────────────────────────────────────────── function TypingIndicator({ theme: T }) { return (
{[0, 1, 2].map(i => (
))}
); } // ── Message group ────────────────────────────────────────────────────────────── function MessageGroup({ group, theme: T }) { const isOwn = group.is_outgoing; const r = T.radius + 4, tight = 6; return (
{group.items.map((m, i) => { const first = i === 0, last = i === group.items.length - 1; const radius = isOwn ? `${r}px ${first ? r : tight}px ${last ? tight : tight}px ${r}px` : `${first ? r : tight}px ${r}px ${r}px ${last ? tight : tight}px`; return (
{m.text}
{formatMsgTime(m.timestamp)} {isOwn && ( )}
); })}
); } // ── Conversation screen ──────────────────────────────────────────────────────── function ConversationScreen({ theme: T, chat, messages, onBack, onSend }) { const [text, setText] = useState(''); const endRef = useRef(null); useEffect(() => { endRef.current?.scrollIntoView({ block: 'end' }); }, [messages.length]); const handleSend = () => { const t = text.trim(); if (!t) return; onSend(t); setText(''); }; const handleKey = (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }; const groups = groupMessages(messages); return (
{/* Header */}
{chat.chat_name}
был(а) недавно
{/* Messages */}
{groups.map((g, i) => g.kind === 'day' ? (
{formatDayHeader(g.ts)}
) : ( ))}
{/* Input */}