// 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 ? (
) : (
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 */}
);
}
// ── App ────────────────────────────────────────────────────────────────────────
function App() {
const isDark = (tg.colorScheme || 'dark') !== 'light';
const T = makeTheme(isDark);
const [stage, setStage] = useState('loading'); // loading | error | empty | list | chat
const [chats, setChats] = useState([]);
const [activeChatId, setActiveChatId] = useState(null);
const [messages, setMessages] = useState([]);
const [errorMsg, setErrorMsg] = useState('');
const activeChat = chats.find(c => String(c.chat_id) === String(activeChatId));
// Sync body background
useEffect(() => { document.body.style.background = T.bg; }, [T.bg]);
// Telegram back button
useEffect(() => {
if (!tg.BackButton) return;
if (stage === 'chat') {
tg.BackButton.show();
tg.BackButton.onClick(handleBack);
return () => { tg.BackButton.offClick(handleBack); tg.BackButton.hide(); };
} else {
tg.BackButton.hide();
}
}, [stage]);
const loadChats = useCallback(async () => {
setStage('loading');
setErrorMsg('');
try {
const data = await apiRequest('/chats');
const list = Array.isArray(data) ? data : (data.chats || []);
setChats(list);
setStage(list.length === 0 ? 'empty' : 'list');
} catch (err) {
console.error('loadChats:', err);
setErrorMsg('Не удалось загрузить чаты. Попробуйте ещё раз.');
setStage('error');
}
}, []);
useEffect(() => {
if (tg.initData || IS_DEV) {
loadChats();
} else {
setErrorMsg('Приложение должно быть открыто из Telegram');
setStage('error');
}
}, [loadChats]);
const handleOpenChat = async (chat) => {
setActiveChatId(chat.chat_id);
setMessages([]);
setStage('chat');
// mark as read locally
setChats(prev => prev.map(c =>
String(c.chat_id) === String(chat.chat_id) ? { ...c, unread_count: 0 } : c
));
try {
const data = await apiRequest('/messages/history', {
method: 'POST',
body: JSON.stringify({ chat_id: chat.chat_id, limit: 50 }),
});
setMessages(data.messages || []);
} catch (err) {
console.error('loadMessages:', err);
}
};
function handleBack() {
setStage('list');
setActiveChatId(null);
setMessages([]);
}
const handleSend = async (text) => {
if (!activeChatId) return;
// Optimistic message
const tmp = {
message_id: 'tmp_' + Date.now(),
chat_id: activeChatId,
sender_id: 0, sender_name: '',
text, timestamp: Date.now(), is_outgoing: true,
};
setMessages(prev => [...prev, tmp]);
setChats(prev => prev.map(c =>
String(c.chat_id) === String(activeChatId)
? { ...c, last_message_text: text, last_message_time: new Date().toISOString() }
: c
));
try {
await apiRequest('/messages/send', {
method: 'POST',
body: JSON.stringify({ chat_id: activeChatId, text }),
});
} catch (err) {
console.error('sendMessage:', err);
}
};
if (stage === 'loading') return ;
if (stage === 'error') return ;
if (stage === 'empty') return ;
if (stage === 'chat' && activeChat)
return ;
return ;
}
ReactDOM.createRoot(document.getElementById('root')).render();