= {
+ uk: 'Ukrainian',
+ ru: 'Russian',
+ en: 'English',
+ ar: 'Arabic',
+ he: 'Hebrew',
+ 'zh-cn': 'Chinese',
+ fr: 'French',
+ de: 'German',
+ pl: 'Polish',
+ };
+ return labels[code] || code.toUpperCase();
+}
+
+function hasTranslation(post: TelegramOsintPost): boolean {
+ const translated = String(post.title_translated || post.description_translated || '').trim();
+ const original = String(post.title || post.description || '').trim();
+ return Boolean(translated && translated !== original);
+}
+
+function postHeadline(post: TelegramOsintPost, showOriginal: boolean): string {
+ const original = String(post.title || post.description || 'Telegram intercept').trim();
+ const translated = String(post.title_translated || post.description_translated || '').trim();
+ if (!showOriginal && translated) {
+ return translated.split('\n', 1)[0].trim();
+ }
+ if (!showOriginal && containsCyrillic(original) && translated) {
+ return translated.split('\n', 1)[0].trim();
+ }
+ return original;
+}
+
+function postDetail(post: TelegramOsintPost, showOriginal: boolean): string | null {
+ if (!showOriginal && post.description_translated) {
+ const translatedTitle = String(post.title_translated || '').trim();
+ const translatedBody = String(post.description_translated || '').trim();
+ if (!translatedBody || translatedBody === translatedTitle) return null;
+ const extra = translatedBody.startsWith(translatedTitle)
+ ? translatedBody.slice(translatedTitle.length).trim()
+ : translatedBody;
+ return extra || null;
+ }
+
const title = String(post.title || '').trim();
const description = String(post.description || '').trim();
if (!description || description === title || description.startsWith(title)) return null;
@@ -126,10 +173,12 @@ function TelegramPostMedia({ post }: { post: TelegramOsintPost }) {
function TelegramPostCard({ post }: { post: TelegramOsintPost }) {
const { t } = useTranslation();
+ const [showOriginal, setShowOriginal] = useState(false);
const rs = post.risk_score ?? 1;
const theme = riskTheme(rs);
- const headline = postHeadline(post);
- const detail = postDetail(post);
+ const translated = hasTranslation(post);
+ const headline = postHeadline(post, showOriginal);
+ const detail = postDetail(post, showOriginal);
const isHigh = rs >= 8;
return (
@@ -150,12 +199,29 @@ function TelegramPostCard({ post }: { post: TelegramOsintPost }) {
{detail}
) : null}
+ {translated && !showOriginal && post.source_lang ? (
+
+ {t('telegram.translatedFrom').replace('{lang}', sourceLangLabel(post))}
+
+ ) : null}
+
{isHigh ? 'BREAKING' : `LVL: ${rs}/10`}
+ {translated ? (
+
+ ) : null}
{post.link ? (
{
+ setLocalizedPosts(posts);
+ }, [posts]);
+
+ useEffect(() => {
+ const needsLocalizedFeed = posts.some((post) => !hasTranslation(post));
+ if (!needsLocalizedFeed) {
+ return;
+ }
+
+ let cancelled = false;
+ const controller = new AbortController();
+
+ fetch(`/api/telegram-feed?lang=${encodeURIComponent(locale)}`, { signal: controller.signal })
+ .then((response) => (response.ok ? response.json() : null))
+ .then((payload) => {
+ if (cancelled || !payload?.posts) return;
+ const byId = new Map(
+ (payload.posts as TelegramOsintPost[]).map((post) => [post.id, post]),
+ );
+ setLocalizedPosts(posts.map((post) => byId.get(post.id) || post));
+ })
+ .catch(() => {
+ /* keep feed posts when locale translation fetch fails */
+ });
+
+ return () => {
+ cancelled = true;
+ controller.abort();
+ };
+ }, [locale, posts]);
+
const sortedPosts = useMemo(
() =>
- [...posts].sort(
+ [...localizedPosts].sort(
(a, b) =>
(b.risk_score ?? 0) - (a.risk_score ?? 0) ||
String(b.published || '').localeCompare(String(a.published || '')),
),
- [posts],
+ [localizedPosts],
);
const maxRisk = sortedPosts[0]?.risk_score ?? 1;
@@ -252,4 +352,4 @@ export function TelegramOsintPopup({ posts, lat, lng, onClose }: TelegramOsintPo
);
-}
+}
\ No newline at end of file
diff --git a/frontend/src/i18n/translations/en.json b/frontend/src/i18n/translations/en.json
index e690d64..7ba3d9a 100644
--- a/frontend/src/i18n/translations/en.json
+++ b/frontend/src/i18n/translations/en.json
@@ -273,6 +273,9 @@
"loadMedia": "VIEW MEDIA (TELEGRAM)",
"openOriginal": "OPEN ON TELEGRAM →",
"embedTitle": "Telegram post embed",
- "postsAtLocation": "{count} posts at this location — scroll for more"
+ "postsAtLocation": "{count} posts at this location — scroll for more",
+ "translatedFrom": "Translated from {lang}",
+ "showOriginal": "SHOW ORIGINAL ({lang})",
+ "showTranslation": "SHOW TRANSLATION"
}
}
diff --git a/frontend/src/i18n/translations/fr.json b/frontend/src/i18n/translations/fr.json
index b71cc72..d6cc2f8 100644
--- a/frontend/src/i18n/translations/fr.json
+++ b/frontend/src/i18n/translations/fr.json
@@ -273,6 +273,9 @@
"loadMedia": "AFFICHER LE MÉDIA (TELEGRAM)",
"openOriginal": "OUVRIR SUR TELEGRAM →",
"embedTitle": "Intégration Telegram",
- "postsAtLocation": "{count} posts à cet endroit — faites défiler"
+ "postsAtLocation": "{count} posts à cet endroit — faites défiler",
+ "translatedFrom": "Traduit depuis le {lang}",
+ "showOriginal": "AFFICHER L'ORIGINAL ({lang})",
+ "showTranslation": "AFFICHER LA TRADUCTION"
}
}
diff --git a/frontend/src/i18n/translations/zh-CN.json b/frontend/src/i18n/translations/zh-CN.json
index 5947697..8207a6a 100644
--- a/frontend/src/i18n/translations/zh-CN.json
+++ b/frontend/src/i18n/translations/zh-CN.json
@@ -273,6 +273,9 @@
"loadMedia": "查看媒体(Telegram)",
"openOriginal": "在 Telegram 打开 →",
"embedTitle": "Telegram 帖子嵌入",
- "postsAtLocation": "此位置 {count} 条帖子 — 向下滚动查看更多"
+ "postsAtLocation": "此位置 {count} 条帖子 — 向下滚动查看更多",
+ "translatedFrom": "译自{lang}",
+ "showOriginal": "显示原文({lang})",
+ "showTranslation": "显示译文"
}
}
diff --git a/frontend/src/types/dashboard.ts b/frontend/src/types/dashboard.ts
index 907cce0..b355eaa 100644
--- a/frontend/src/types/dashboard.ts
+++ b/frontend/src/types/dashboard.ts
@@ -972,6 +972,11 @@ export interface TelegramOsintPost {
id: string;
title?: string;
description?: string;
+ title_translated?: string;
+ description_translated?: string;
+ source_lang?: string;
+ source_lang_label?: string;
+ translate_to?: string;
link?: string;
published?: string;
source?: string;