From d21c6637bf3a26fe7a6a7822eadc93b0de18de83 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 4 May 2026 06:18:12 +0300 Subject: [PATCH] feat(admin/broadcasts): add preview buttons for Telegram + Email broadcasts Add BroadcastPreview component with local rendering of Telegram messages (HTML tokenizer with tag whitelist, no dangerouslySetInnerHTML) and Email preview (sandboxed iframe). Preview buttons appear in broadcast create page section headers, disabled when content is empty. Based on PR #416 by @smediainfo. --- .../broadcasts/BroadcastPreview.tsx | 287 ++++++++++++++++++ src/pages/AdminBroadcastCreate.tsx | 84 ++++- 2 files changed, 365 insertions(+), 6 deletions(-) create mode 100644 src/components/broadcasts/BroadcastPreview.tsx diff --git a/src/components/broadcasts/BroadcastPreview.tsx b/src/components/broadcasts/BroadcastPreview.tsx new file mode 100644 index 0000000..91e3fbe --- /dev/null +++ b/src/components/broadcasts/BroadcastPreview.tsx @@ -0,0 +1,287 @@ +import { useMemo, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; +import { useTranslation } from 'react-i18next'; + +interface PreviewButton { + text: string; + url?: string; + callback_data?: string; +} + +interface TelegramPreviewProps { + open: boolean; + onClose: () => void; + text: string; + mediaUrl?: string | null; + mediaType?: 'photo' | 'video' | null; + buttons?: PreviewButton[][]; +} + +interface EmailPreviewProps { + open: boolean; + onClose: () => void; + subject: string; + htmlContent: string; +} + +interface Token { + kind: 'text' | 'open' | 'close' | 'br'; + tag?: string; + href?: string; + value?: string; +} + +const TG_TAGS = new Set([ + 'b', + 'strong', + 'i', + 'em', + 'u', + 'ins', + 's', + 'strike', + 'del', + 'code', + 'pre', + 'a', + 'tg-spoiler', + 'span', +]); + +function tokenize(input: string): Token[] { + const tokens: Token[] = []; + const pattern = /<(\/?)([a-z][a-z0-9-]*)(\s+[^>]*)?>|/gi; + const matches = [...input.matchAll(pattern)]; + let lastIdx = 0; + for (const m of matches) { + const idx = m.index || 0; + if (idx > lastIdx) tokens.push({ kind: 'text', value: input.slice(lastIdx, idx) }); + if (m[0].toLowerCase().startsWith(' (i === 0 ? [p] : [
, p])); +} + +type Frame = { tag: string | null; href?: string; children: ReactNode[] }; + +function wrap(frame: Frame, key: number): ReactNode { + const k = `el-${key}`; + switch (frame.tag) { + case 'b': + case 'strong': + return {frame.children}; + case 'i': + case 'em': + return {frame.children}; + case 'u': + case 'ins': + return {frame.children}; + case 's': + case 'strike': + case 'del': + return {frame.children}; + case 'code': + return ( + + {frame.children} + + ); + case 'pre': + return ( +
+          {frame.children}
+        
+ ); + case 'a': { + const safeHref = + frame.href && /^(https?:|tg:|mailto:|tel:)/i.test(frame.href) ? frame.href : '#'; + return ( + + {frame.children} + + ); + } + case 'tg-spoiler': + case 'span': + return {frame.children}; + default: + return {frame.children}; + } +} + +function tokensToReact(tokens: Token[]): ReactNode { + const root: Frame = { tag: null, children: [] }; + const stack: Frame[] = [root]; + let textKey = 0; + let elKey = 0; + for (const tok of tokens) { + const top = stack[stack.length - 1]; + if (tok.kind === 'text') { + top.children.push({renderText(tok.value || '', textKey)}); + } else if (tok.kind === 'br') { + top.children.push(
); + } else if (tok.kind === 'open') { + stack.push({ tag: tok.tag!, href: tok.href, children: [] }); + } else if (tok.kind === 'close') { + let foundIdx = -1; + for (let i = stack.length - 1; i >= 1; i--) { + if (stack[i].tag === tok.tag) { + foundIdx = i; + break; + } + } + if (foundIdx === -1) continue; + while (stack.length > foundIdx) { + const closed = stack.pop()!; + const parent = stack[stack.length - 1] || root; + parent.children.push(wrap(closed, elKey++)); + } + } + } + while (stack.length > 1) { + const closed = stack.pop()!; + const parent = stack[stack.length - 1]; + parent.children.push(wrap(closed, elKey++)); + } + return root.children; +} + +export function TelegramPreview({ + open, + onClose, + text, + mediaUrl, + mediaType, + buttons, +}: TelegramPreviewProps) { + const { t } = useTranslation(); + const rendered = useMemo(() => tokensToReact(tokenize(text)), [text]); + if (!open) return null; + return createPortal( +
+
e.stopPropagation()} + > +
+

+ {t('admin.broadcasts.preview', 'Предпросмотр Telegram')} +

+ +
+
+
+ {mediaUrl && mediaType === 'photo' && ( + + )} + {mediaUrl && mediaType === 'video' && ( +
+ {buttons && buttons.length > 0 && ( +
+ {buttons.map((row, ri) => ( +
+ {row.map((b, ci) => ( + + ))} +
+ ))} +
+ )} +
+
+
, + document.body, + ); +} + +export function EmailPreview({ open, onClose, subject, htmlContent }: EmailPreviewProps) { + const { t } = useTranslation(); + if (!open) return null; + const emptyHtml = `

${t('admin.broadcasts.previewEmpty', '— пусто —')}

`; + return createPortal( +
+
e.stopPropagation()} + > +
+
+
+ {t('admin.broadcasts.emailSubject', 'Тема')} +
+
+ {subject || t('admin.broadcasts.previewEmpty', '— пусто —')} +
+
+ +
+