/* #14520 — selection UI; delivery and five-second undo live on the server. */ function reviewBlocked(message) { return ['queued', 'sending', 'sent', 'unknown'].includes(message.reviewForward?.state); } function reviewEligible(message) { return message.kind === 'user' && Number.isSafeInteger(message.telegramMessageId) && message.telegramMessageId > 0 && !message.telegramProtected; } function ReviewMessage({ message, reviews, children }) { if (!reviews) return children; const metadata = message.reviewForward; const selected = reviews.selected.has(message.id); const selectable = reviewEligible(message) && !reviewBlocked(message); const labels = {queued: 'Ожидает пересылки', sending: 'Пересылаем…', sent: 'В отзывах', unknown: 'Результат неизвестен · проверьте канал', failed: 'Не отправлено'}; return
reviews.openMenu(event, message)} className={selected ? 'rounded-xl bg-[rgb(var(--hd-accent))]/10 ring-1 ring-[rgb(var(--hd-accent))]/40' : ''}>
{reviews.selecting && reviews.toggle(message.id)} className="mt-3 ml-2 shrink-0 accent-[rgb(var(--hd-accent))]" />}
{children}
{labels[metadata?.state] &&
{labels[metadata.state]}{metadata.state === 'sent' && ` · ${metadata.operator} · ${new Date(metadata.at).toLocaleString('ru-RU')}`} {metadata.error && {metadata.error}}
}
; } function useReviewForwarding({ active, pastTickets, setPastTickets, setConversations, showToast, locked }) { const [menu, setMenu] = React.useState(null); const [selecting, setSelecting] = React.useState(false); const [selected, setSelected] = React.useState(new Set()); const [preview, setPreview] = React.useState(false); const [config, setConfig] = React.useState(null); const [busy, setBusy] = React.useState(false); const [error, setError] = React.useState(''); const [job, setJob] = React.useState(null); const [remaining, setRemaining] = React.useState(0); const [tracked, setTracked] = React.useState(() => { try { return sessionStorage.getItem('hd_review_batch') || ''; } catch { return ''; } }); const gate = React.useRef(false); const menuRef = React.useRef(null); const activeRef = React.useRef(active?.id); activeRef.current = active?.id; const allMessages = [...pastTickets.flatMap(ticket => ticket.messages), ...(active?.messages || [])]; const selectedMessages = allMessages.filter(message => selected.has(message.id) && reviewEligible(message) && !reviewBlocked(message)); React.useEffect(() => { if (menu) (menuRef.current?.querySelector('button:not(:disabled)') || menuRef.current)?.focus(); }, [menu]); function exit() { if (!gate.current) { setSelecting(false); setSelected(new Set()); setPreview(false); setMenu(null); setError(''); } } React.useEffect(() => { setSelecting(false); setSelected(new Set()); setPreview(false); setMenu(null); setError(''); }, [active?.id]); React.useEffect(() => { function key(event) { if (event.key !== 'Escape') return; if (menu) setMenu(null); else if (preview) setPreview(false); else exit(); } function outside(event) { if (!event.target.closest('[data-review-menu]')) setMenu(null); } function scroll() { setMenu(null); } document.addEventListener('keydown', key); document.addEventListener('pointerdown', outside); document.addEventListener('scroll', scroll, true); window.addEventListener('resize', scroll); return () => { document.removeEventListener('keydown', key); document.removeEventListener('pointerdown', outside); document.removeEventListener('scroll', scroll, true); window.removeEventListener('resize', scroll); }; }, [menu, preview]); React.useEffect(() => { if (!preview) return; let alive = true; setConfig(null); apiGet('/api/reviews/config').then(value => { if (alive) setConfig(value); }) .catch(() => { if (alive) setError('Не удалось проверить настройки канала. Откройте превью снова.'); }); return () => { alive = false; }; }, [preview]); function applyStatus(value) { const updates = new Map(value.messages.map(message => [message.id, message.reviewForward])); const update = rows => rows?.map(message => updates.has(message.id) ? {...message, reviewForward: updates.get(message.id)} : message); setConversations(rows => rows.map(dialog => ({...dialog, messages: update(dialog.messages)}))); setPastTickets(rows => rows.map(ticket => ({...ticket, messages: update(ticket.messages)}))); setJob(value); setRemaining(Math.max(0, Math.ceil((new Date(value.dueAt) - new Date(value.serverTime)) / 1000))); } React.useEffect(() => { if (!tracked) return; let alive = true, timer; async function poll() { try { const result = await apiGet(`/api/reviews/batches/${tracked}`); if (!alive) return; applyStatus(result); setError(''); if (['done', 'cancelled'].includes(result.state)) { setTracked(''); try { sessionStorage.removeItem('hd_review_batch'); } catch {} if (result.state === 'done') { const count = result.messages.filter(message => message.reviewForward?.state === 'sent').length; showToast(`В отзывы переслано: ${count} из ${result.messages.length}`); } return; } } catch (err) { if (alive) setError('Статус пересылки недоступен. Проверяем снова…'); } if (alive) timer = setTimeout(poll, 500); } poll(); return () => { alive = false; clearTimeout(timer); }; }, [tracked]); function openMenu(event, message) { // Only customer messages intercept native context menu. event.preventDefault(); if (busy || tracked || locked) return; setMenu({message, x: Math.max(8, Math.min(event.clientX, window.innerWidth - 280)), y: Math.max(8, Math.min(event.clientY, window.innerHeight - 60))}); } function toggle(id) { if (gate.current) return; setSelected(previous => { const next = new Set(previous); if (next.has(id)) next.delete(id); else if (next.size < 100) next.add(id); return next; }); } function start(message) { if (!reviewEligible(message) || reviewBlocked(message)) return; setSelected(new Set([message.id])); setSelecting(true); setMenu(null); setError(''); } async function confirm() { if (gate.current || !config?.enabled || !selectedMessages.length || tracked) return; gate.current = true; setBusy(true); setError(''); const ident = crypto.randomUUID(); const dialogId = active?.id; try { const result = await apiPost(`/api/dialogs/${encodeURIComponent(dialogId)}/reviews`, { request_id: ident, message_ids: selectedMessages.map(message => message.id)}); applyStatus(result); setPreview(false); setSelecting(false); setSelected(new Set()); try { sessionStorage.setItem('hd_review_batch', ident); } catch {} setTracked(ident); } catch (err) { // A lost HTTP reply can follow a committed reservation. Check its stable ID. try { const result = await apiGet(`/api/reviews/batches/${ident}`); applyStatus(result); setTracked(ident); setSelecting(false); setPreview(false); try { sessionStorage.setItem('hd_review_batch', ident); } catch {} } catch { if (activeRef.current === dialogId) setError(err.detail || err.message || 'Не удалось запланировать пересылку.'); } } finally { gate.current = false; setBusy(false); } } async function cancelJob() { if (gate.current || !tracked) return; gate.current = true; setBusy(true); try { const result = await apiFetch('DELETE', `/api/reviews/batches/${tracked}`); applyStatus(result); setTracked(''); setJob(null); setError(''); try { sessionStorage.removeItem('hd_review_batch'); } catch {} } catch (err) { setError(err.detail || err.message || 'Не удалось отменить. Проверяем статус.'); } finally { gate.current = false; setBusy(false); } } const disabledMenu = menu && (!reviewEligible(menu.message) || reviewBlocked(menu.message)); const menuText = menu?.message.reviewForward?.state === 'sent' ? 'Уже в отзывах' : reviewBlocked(menu?.message || {}) ? 'Пересылка уже запрошена' : menu && !reviewEligible(menu.message) ? 'Пересылка недоступна' : 'Переслать в отзывы'; const overlays = <> {menu &&
} {preview && { if (!gate.current) setPreview(false); }} onConfirm={confirm} />} {job && (tracked || job.state === 'done') &&
{job.state === 'queued' ? `Пересылаем… ${remaining > 0 ? `${remaining} с` : ''}` : job.state === 'processing' ? 'Пересылаем…' : 'Пересылка завершена'} {job.state === 'queued' && } {job.state === 'done' && } {job.state === 'done' && job.messages.some(message => message.reviewForward?.state !== 'sent') &&
Часть сообщений не отправлена или требует проверки. Подробности — под сообщениями.
} {error &&
{error}
}
} ; const panel = selecting &&
Выбрано: {selectedMessages.length}
; return {selecting, selected, busy, openMenu, toggle, overlays, panel}; } function ReviewPreview({ messages, author, config, error, busy, onCancel, onConfirm }) { const closeRef = React.useRef(null); React.useEffect(() => { const previous = document.activeElement; closeRef.current?.focus(); return () => previous?.isConnected && previous.focus(); }, []); return
{ if (event.target === event.currentTarget) onCancel(); }}>
{ if (event.key !== 'Tab') return; const buttons = Array.from(event.currentTarget.querySelectorAll('button:not(:disabled)')); if (!buttons.length) return; if (event.shiftKey && document.activeElement === buttons[0]) { event.preventDefault(); buttons.at(-1).focus(); } else if (!event.shiftKey && document.activeElement === buttons.at(-1)) { event.preventDefault(); buttons[0].focus(); } }} className="w-full max-w-lg rounded-2xl border border-[rgb(var(--hd-dim))] bg-[rgb(var(--hd-surface))] p-5 shadow-2xl">

Переслать в отзывы · {messages.length}

Превью канала. Telegram может скрыть профиль автора. После подтверждения будет 5 секунд на отмену.

{messages.map(message =>
Переслано от {author || 'Клиент'}
{message.fileType && message.fileType !== 'text' &&
{}} />
} {message.text &&
{message.text}
}
)}
{!config ?

Проверяем настройки…

: !config.enabled &&

Канал отзывов пока не настроен. Отправка недоступна.

} {error &&

{error}

}
; }