// pages-comm.jsx — Chats + Checks (NEW) const { useState: useStateC, useMemo: useMemoC, useRef: useRefC, useEffect: useEffectC } = React; /* ============================ CHATS ============================ */ // PHASE D — визуальное выделение «тяжёлых» клиентов по кол-ву сообщений. // Чем больше сожжено токенов/времени — тем заметнее. Без сложного дизайна. function _heavyMessagesClass(n) { const m = Number(n) || 0; if (m >= 1000) return "bg-rose-500/20 text-rose-600 font-bold ring-1 ring-rose-500/40"; if (m >= 500) return "bg-orange-500/20 text-orange-700 font-semibold"; if (m >= 250) return "bg-amber-500/20 text-amber-600"; if (m >= 100) return "bg-yellow-500/15 text-yellow-700"; return "text-zinc-500"; } function ChatsPage() { // ВНИМАНИЕ ЗА РЕФАКТОРИНГОМ: // Это первая страница мигрированная на runtime.js (Фаза 2). // Раньше тут был useEffect+setInterval+stopped с ручным polling 20с. // Теперь useQuery делает всё: dedupe, AbortController, polling через // глобальный scheduler, visibility-pause. UI state (sort/filter/search/ // selected) остаётся локальным — это правильно, это user intent а не data. const [sort, setSort] = useStateC("recent"); // recent (last_message) | messages const [direction, setDirection] = useStateC("desc"); // desc (больше/новее сверху) | asc const [filter, setFilter] = useStateC("all"); // all | blocked const [search, setSearch] = useStateC(""); const [selected, setSelected] = useStateC(null); const [busyBlock, setBusyBlock] = useStateC(null); // convId в процессе block/unblock const scrollMemoryRef = useRefC(new Map()); // Фильтрация/сортировка/поиск на СЕРВЕРЕ (полный dataset). Сортировка — по // времени или по кол-ву сообщений, направление desc/asc (toggle). const serverSort = sort === "messages" ? "messages" : "last_message"; const blockedOnly = filter === "blocked"; const fetchChatsList = React.useCallback( (signal) => fetchChats(500, { signal, sort: serverSort, direction, blockedOnly, q: search, }), [serverSort, direction, blockedOnly, search] ); const { data: chatsData, loading, error, refetch } = useQuery( ["chats", "list", serverSort, direction, filter, search], fetchChatsList, { pollInterval: 3_000 } // лайв-список диалогов (было 20с) ); const chats = chatsData || []; // PHASE C/D — Block / Unblock без перезагрузки страницы. const handleBlockToggle = React.useCallback(async (chat) => { if (!chat || busyBlock) return; setBusyBlock(chat.id); try { if (chat.blocked) await unblockChat(chat.id); else await blockChat(chat.id); await refetch(); // обновить актуальный blocked-статус } catch (e) { console.error("block toggle failed", e); } finally { setBusyBlock(null); } }, [busyBlock, refetch]); // Sprint α — counts по всему dataset (не зависит от limit). Раньше // «Всего чатов: 100» залипало потому что считалось как chats.length. // Polling реже (30 сек) — totals меняются медленно. const fetchCounts = React.useCallback( (signal) => fetchChatsCounts({ signal }), [] ); const { data: countsData } = useQuery( ["chats", "counts"], fetchCounts, { pollInterval: 30_000 } ); // Auto-select первого чата при первой загрузке — раньше это было // внутри refresh(). Теперь — отдельный эффект на смену списка. React.useEffect(() => { if (!selected && chats.length > 0) { setSelected(chats[0].id); } }, [chats, selected]); // Фильтрация/сортировка/поиск делаются на сервере (см. fetchChatsList). const filtered = chats; const stats = useMemoC(() => { // Sprint α — для totalChats/withCheck/paid используем counts (по всему // dataset, не по странице). totalMessages пока остаётся sum по странице // потому что серверный endpoint counts его не считает (Sprint α scope — // только counts чатов, не суммарные сообщения). return { totalMessages: chats.reduce((s, c) => s + c.messages, 0), withCheck: countsData?.hasCheck ?? chats.filter((c) => c.hasCheck).length, paid: countsData?.paid ?? chats.filter((c) => c.paid).length, totalChats: countsData?.total ?? chats.length, }; }, [chats, countsData]); const current = chats.find((c) => c.id === selected); return (
{/* Второй ряд — активность чатов по времени */}
setSearch(e.target.value)} placeholder="Поиск: имя / username / id…" className="w-full bg-white/50 border border-black/10 rounded-lg pl-9 pr-3 py-2 text-sm placeholder:text-zinc-500 focus:outline-none focus:border-indigo-500/50" />
{/* Фильтр: все / заблокированные */}
{[ { id: "all", label: "Все" }, { id: "blocked", label: "Заблокированные" }, ].map((f) => ( ))}
{/* Сортировка: критерий + направление (toggle ↓/↑) */}
{[ { id: "recent", label: "По времени" }, { id: "messages", label: "По сообщениям" }, ].map((o) => ( ))}
{loading && Array.from({ length: 8 }).map((_, i) => (
))} {!loading && filtered.length === 0 && (
{chats.length === 0 ? "Диалогов пока нет" : "Ничего не найдено"}
)} {!loading && filtered.map((c) => { const isActive = selected === c.id; return (
setSelected(c.id)} title={(c.username ? "@" + String(c.username).replace(/^@/, "") + " · " : "") + "id " + c.clientId} className={"relative w-full px-3.5 py-2.5 border-b border-black/[0.05] flex items-start gap-2.5 text-left cursor-pointer transition-colors " + (isActive ? "bg-[rgba(231,220,196,0.55)]" : "hover:bg-black/[0.03]") + (c.blocked ? " opacity-80" : "")} > {isActive && }
{c.hasCheck && (
)}
{c.name} {c.blocked && ( Blocked )}
{c.lastAt}
{c.preview}
{(c.account.name || "?").slice(0, 1).toUpperCase()} {c.account.name} {c.paid && оплачен} {c.messages}
); })}
{current ? ( <>
{current.name} {current.username ? @{String(current.username).replace(/^@/, "")} : null}
через {current.account.name} · {current.messages} сообщений · id {current.clientId}
{current.hasCheck && чек} {current.paid && оплачен}
) : (
Выберите чат слева
)}
); } function DialogBubble({ side, text, time, status, kind, mediaUrl, mediaCount, fileName }) { const isRight = side === "right"; const isPhoto = kind === "photo"; const isVideo = kind === "video"; const isCircle = kind === "video_note"; const isVoice = kind === "voice"; const isDocument = kind === "document"; // Шаг 4: PDF / любой файл const hasMedia = mediaUrl && (isPhoto || isVideo || isCircle || isDocument); return (
{hasMedia && isPhoto && ( )} {hasMedia && isVideo && (
); } /* ============================ Chat history generator + scroller ============================ */ // Tiny seeded RNG so each chat has stable history function mulberry32(seed) { return function () { let t = (seed += 0x6D2B79F5); t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } const CLIENT_LINES = [ "Привет 👋", "Скинь прайс пожалуйста", "А что есть из видео?", "А виртик у тебя есть?", "Сколько за 3 фото?", "Можно набор 5+3?", "А кружки делаешь?", "Скинь реквизиты", "Privat подойдёт?", "Сбер можно?", "Карту тинькоффа дашь?", "USDT принимаешь?", "Через Stars можно?", "Сейчас оплачу", "Оплатил, держи чек", "Чек скинул, проверь пожалуйста", "Получила?", "Ну как там?", "Долго ждать?", "Спасибо ❤️", "Огонь, ещё что-то есть?", "А приват сколько стоит?", "Можно фото без лица?", "А с игрушкой видео есть?", "Звонок 30 мин сколько?", "Завтра напишу ещё", "Ок, договорились", "Спасибо, всё ок 🌸", "Слушай, а ещё контент будет?", "А скидку дашь если возьму набор?", ]; const SELLER_LINES = [ "Привет 💋", "Скидываю прайс 🌸", "Хорошо, сейчас отправлю реквизиты", "Карта Privat: 4441 1110 6974 7248 — как оплатишь, кидай чек сюда 🌷", "Сбер: 2202 2018 4421 9043, держатель Анна К.", "Принимаю и USDT (TRC-20)", "Принято, проверяю ⏳", "Чек ок, отправляю 💧", "Готово, держи 🌸", "Спасибо солнышко ❤️", "Конечно, заходи в любое время 💋", "Сейчас сделаю и пришлю", "Записала тебя", "Ну ты огонь 🔥", "Прайс актуален, скидывать?", "Минимум 3 фото — 100₴", "Набор 5+3 = 400₴, выгоднее", "Кружки делаю, 150₴ за один", "Скинула, посмотри", "Спасибо, рада что понравилось 🌷", "Виртик 30 мин — 400₴, переписка с фоточками", "Приват навсегда 1200₴", "Скидку могу дать на набор — 350 вместо 400", ]; function generateChatHistory(chat) { const seed = chat.id.split("").reduce((s, c) => s + c.charCodeAt(0), 0); const rnd = mulberry32(seed); const totalMessages = Math.max(20, Math.min(80, chat.messages + 12 + Math.floor(rnd() * 20))); const messages = []; // Build messages spread across the last 5 days (most clustered today/yesterday) let dayOffset = Math.min(4, Math.floor(rnd() * 4) + 1); // start N days ago let hour = 9 + Math.floor(rnd() * 4); let minute = Math.floor(rnd() * 60); // Distribute msgs: 60% today, 25% yesterday, 15% earlier for (let i = 0; i < totalMessages; i++) { const remaining = totalMessages - i; if (dayOffset > 0 && i / totalMessages > 1 - (dayOffset === 1 ? 0.35 : dayOffset === 2 ? 0.5 : 0.85)) { // jump to next day, reset hour dayOffset--; hour = 9 + Math.floor(rnd() * 3); minute = Math.floor(rnd() * 30); } // Advance time 1–25 minutes minute += 1 + Math.floor(rnd() * 24); while (minute >= 60) { minute -= 60; hour++; } if (hour >= 24) { hour = 9; } const isClient = i === 0 ? true : rnd() < 0.5; const pool = isClient ? CLIENT_LINES : SELLER_LINES; const text = pool[Math.floor(rnd() * pool.length)]; const time = String(hour).padStart(2, "0") + ":" + String(minute).padStart(2, "0"); // Inject a check image somewhere mid-conversation (~75% of the way) const injectCheck = chat.hasCheck && i === Math.floor(totalMessages * 0.75) && isClient; messages.push({ id: chat.id + "_m" + i, side: isClient ? "left" : "right", text, time, dayOffset, kind: injectCheck ? "check" : "text", status: !isClient ? (i < totalMessages - 2 ? "read" : "sent") : null, }); } return messages; } function formatDayLabel(offset) { if (offset === 0) return "Сегодня"; if (offset === 1) return "Вчера"; const days = ["дн", "дн", "дн", "дн"]; const labels = ["2 дня назад", "3 дня назад", "4 дня назад", "5 дней назад"]; return labels[offset - 2] || `${offset} дней назад`; } /* ============================================================ * ChatScroll — Sprint β * ============================================================ * Invariants (см. contract обсуждения β): * 1. Anchor from bottom: distance до низа const при polling/prepend. * 2. Merge by id everywhere — единый dedupe primitive. * 3. Stable ordering: (ts ISO, idx) tiebreak. * 4. Referential identity: одинаковые messages re-use existing object. * 5. hasMore=false — final до chatId change (no polling storm). * 6. loadingMore — hard lock от parallel prepend. * 7. chatIdRef snapshot — stale responses ignored. * 8. → re-anchor (capture phase listener). * 9. scrollMemory map persists scroll position per chat for A→B→A. * 10. UX continuity > freshness — все merges батчатся через * useLayoutEffect ДО paint, юзер не видит "промежуточных" frame. */ // Sprint β — единая функция merge для всех путей (polling/prepend/initial). // preserveExistingReferences: для каждого сообщения проверяем — если в // existing уже есть с тем же id И поля shallow-equal → keep existing // reference. Иначе берём incoming. Это критично для будущих reactions/edits. function _shallowMessageEqual(a, b) { if (a === b) return true; if (!a || !b) return false; // Поля которые могут стабильно меняться: text, status, mediaUrl, kind, fileName return ( a.id === b.id && a.side === b.side && a.text === b.text && a.time === b.time && a.kind === b.kind && a.status === b.status && a.mediaUrl === b.mediaUrl && a.mediaCount === b.mediaCount && a.fileName === b.fileName && a.ts === b.ts && a.idx === b.idx && a.dayOffset === b.dayOffset ); } function _mergeMessages(existing, incoming) { // Map by id, primary order = incoming wins, identity preserved on equality. const byId = new Map(); for (const m of existing) byId.set(m.id, m); let changed = false; for (const m of incoming) { const prev = byId.get(m.id); if (prev && _shallowMessageEqual(prev, m)) { // re-use existing reference — referential identity preserved. continue; } // Sprint β-fix #1: collision warning. Если id тот же но содержимое // разное — это потенциальный data-loss bug (overwrite). После фикса // backend id-генератора такого быть не должно. Если warning появится — // значит мы поймали реальный edge case. if (prev && !_shallowMessageEqual(prev, m)) { console.warn( "[merge collision] same id, different content — possible data loss", { id: m.id, prev, incoming: m } ); if (typeof window !== "undefined" && window.__chatScrollStats) { window.__chatScrollStats.collisions = (window.__chatScrollStats.collisions || 0) + 1; } } byId.set(m.id, m); changed = true; } if (!changed && byId.size === existing.length) return existing; // Stable sort: ts primary (ISO string comparable), idx secondary. const list = Array.from(byId.values()); list.sort((a, b) => { const tA = a.ts || ""; const tB = b.ts || ""; if (tA !== tB) return tA < tB ? -1 : 1; const iA = typeof a.idx === "number" ? a.idx : 0; const iB = typeof b.idx === "number" ? b.idx : 0; return iA - iB; }); return list; } function ChatScroll({ chatId, scrollMemory }) { const scrollRef = useRefC(null); const [atBottom, setAtBottom] = useStateC(true); // Накопленные сообщения (включая prepend через infinite scroll). // useQuery даёт только initial 200; prepend добавляет через loadMore. const [allMessages, setAllMessages] = useStateC([]); const [hasMore, setHasMore] = useStateC(true); const [loadingMore, setLoadingMore] = useStateC(false); // Refs которые не триггерят re-render const chatIdRef = useRefC(chatId); const savedDistanceRef = useRefC(0); // distance from bottom — invariant для anchor const atBottomRef = useRefC(true); const loadingMoreRef = useRefC(false); const hasMoreRef = useRefC(true); const abortRef = useRefC(null); const prevMessagesLenRef = useRefC(0); // для скана delta height // HUD stats — глобальный bucket, читается debug-hud.jsx через polling. // Никаких subscriptions, никаких React-renders по этим инкрементам. if (typeof window !== "undefined" && !window.__chatScrollStats) { window.__chatScrollStats = { prepends: 0, merges: 0, scrollRestores: 0, staleIgnored: 0, mediaLoadRestores: 0, currentChatId: null, messagesCount: 0, hasMore: true, loadingMore: false, atBottom: true, oldestTs: null, }; } const statsBump = (key) => { if (typeof window === "undefined" || !window.__chatScrollStats) return; window.__chatScrollStats[key] = (window.__chatScrollStats[key] || 0) + 1; }; const statsSet = (patch) => { if (typeof window === "undefined" || !window.__chatScrollStats) return; Object.assign(window.__chatScrollStats, patch); }; // Initial load via useQuery (Sprint α infrastructure). При chatId change // useQuery key меняется → новый fetch с AbortController, старый прерывается. const fetcher = React.useCallback( (signal) => fetchChatMessages(chatId, 200, { signal }), [chatId] ); const { data: initialData, loading: loadingMsgs } = useQuery( ["chat-messages", chatId], fetcher, { pollInterval: 2_000, enabled: !!chatId } // лайв открытого чата (было 12с) ); // При смене chatId — reset infinite-scroll state. Cache в useQuery для // {chatId} остаётся (instant return при возврате на этот чат). React.useEffect(() => { chatIdRef.current = chatId; setHasMore(true); hasMoreRef.current = true; setLoadingMore(false); loadingMoreRef.current = false; setAllMessages([]); // сбрасываем — initialData приедет следующим эффектом if (abortRef.current) { try { abortRef.current.abort(); } catch {} abortRef.current = null; } }, [chatId]); // Когда initialData (polling tick или first load) приходит — merge с // существующим allMessages (там могут быть prepend-сообщения сверху). // Это main entry для polling → merge → useLayoutEffect → anchor restore. React.useEffect(() => { if (!initialData) return; setAllMessages((prev) => { const merged = _mergeMessages(prev, initialData); if (merged !== prev) statsBump("merges"); return merged; }); }, [initialData]); // ===== ANCHOR PRESERVATION ===== // useLayoutEffect выполняется после DOM mutation ДО paint. Юзер никогда // не видит "scroll прыгнул". Алгоритм: // - Если atBottom (юзер был на дне): scroll вниз → "новое сообщение // появилось" feel. // - Иначе: восстанавливаем distance от низа → scroll не сдвинется. // // Этот эффект триггерится на каждое allMessages change. React.useLayoutEffect(() => { const el = scrollRef.current; if (!el) return; if (atBottomRef.current) { el.scrollTop = el.scrollHeight; savedDistanceRef.current = 0; } else { // Restore distance from bottom — главный invariant. const targetTop = el.scrollHeight - el.clientHeight - savedDistanceRef.current; el.scrollTop = Math.max(0, targetTop); } statsBump("scrollRestores"); prevMessagesLenRef.current = allMessages.length; statsSet({ messagesCount: allMessages.length, oldestTs: allMessages[0]?.ts || null }); }, [allMessages]); // ===== INITIAL POSITIONING при смене chatId ===== // Если в scrollMemory есть сохранённая позиция → restore. Иначе → bottom. React.useLayoutEffect(() => { const el = scrollRef.current; if (!el) return; const saved = scrollMemory && scrollMemory.current ? scrollMemory.current.get(chatId) : null; // Откладываем до полного рендера сообщений (через rAF чтобы DOM измерения // были после layout) const jump = () => { if (!scrollRef.current || chatIdRef.current !== chatId) return; if (saved && saved.atBottom === false) { // restore exact scroll position scrollRef.current.scrollTop = saved.scrollTop; atBottomRef.current = false; setAtBottom(false); savedDistanceRef.current = scrollRef.current.scrollHeight - scrollRef.current.clientHeight - scrollRef.current.scrollTop; } else { // default: bottom scrollRef.current.scrollTop = scrollRef.current.scrollHeight; atBottomRef.current = true; setAtBottom(true); savedDistanceRef.current = 0; } }; jump(); const raf1 = requestAnimationFrame(jump); const raf2 = requestAnimationFrame(() => requestAnimationFrame(jump)); const t = setTimeout(jump, 80); return () => { cancelAnimationFrame(raf1); cancelAnimationFrame(raf2); clearTimeout(t); // Save position для A→B→A — пишем в memory map. const el2 = scrollRef.current; if (el2 && scrollMemory && scrollMemory.current) { scrollMemory.current.set(chatId, { scrollTop: el2.scrollTop, atBottom: atBottomRef.current, }); } }; }, [chatId]); // ===== SCROLL HANDLER ===== // Дешёвый — refs только, setState только при пересечении atBottom threshold. // Триггерит infinite-scroll загрузку при scrollTop < 200 (если hasMore). React.useEffect(() => { const el = scrollRef.current; if (!el) return; const onScroll = () => { const dist = el.scrollHeight - el.clientHeight - el.scrollTop; savedDistanceRef.current = dist; const isBottom = dist < 40; if (isBottom !== atBottomRef.current) { atBottomRef.current = isBottom; setAtBottom(isBottom); statsSet({ atBottom: isBottom }); } // Infinite scroll trigger if ( el.scrollTop < 200 && hasMoreRef.current && !loadingMoreRef.current && allMessages.length > 0 ) { loadMore(); } }; el.addEventListener("scroll", onScroll); return () => el.removeEventListener("scroll", onScroll); // eslint-disable-next-line react-hooks/exhaustive-deps }, [allMessages.length, chatId]); // ===== INFINITE SCROLL PREPEND ===== const loadMore = React.useCallback(async () => { if (loadingMoreRef.current || !hasMoreRef.current) return; if (allMessages.length === 0) return; const oldest = allMessages[0]; if (!oldest || !oldest.ts) return; loadingMoreRef.current = true; setLoadingMore(true); statsSet({ loadingMore: true }); const snapshot = chatIdRef.current; const controller = new AbortController(); abortRef.current = controller; try { const older = await fetchChatMessages(chatId, 200, { before: oldest.ts, signal: controller.signal, }); // Stale response check (юзер ушёл в другой чат пока ждали) if (chatIdRef.current !== snapshot) { statsBump("staleIgnored"); return; } if (!older || older.length === 0) { hasMoreRef.current = false; setHasMore(false); statsSet({ hasMore: false }); return; } // Если бэк вернул МЕНЬШЕ limit — это последняя страница. if (older.length < 200) { hasMoreRef.current = false; setHasMore(false); statsSet({ hasMore: false }); } // Single merge primitive — то же что для polling. setAllMessages((prev) => { const merged = _mergeMessages(prev, older); if (merged !== prev) { statsBump("merges"); statsBump("prepends"); } return merged; }); } catch (e) { if (e && e.name === "AbortError") return; console.error("loadMore failed", e); } finally { loadingMoreRef.current = false; setLoadingMore(false); statsSet({ loadingMore: false }); if (abortRef.current === controller) abortRef.current = null; } // eslint-disable-next-line react-hooks/exhaustive-deps }, [allMessages, chatId]); // ===== MEDIA onLOAD — re-anchor ===== // Когда /