/* #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}
}
;
}