import { useState, useEffect } from "react"; import { getGameTitle, getShortenedGameName } from "../utils.ts"; import { useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; export interface NewsData { date: string; identifier: string; type: string | null; timestamp: number; headline: string | null; content: string; url: string | null; images: Array<{ image: string; link: string | null; }>; en_headline: string | null; en_content: string | null; is_ai_summary: boolean | null; archive_hash: string | null; } interface NewsFeedProps { newsItems: NewsData[]; } type Sentinel = { _sentinel: "nothing-new" | "caught-up" }; const VIEWED_KEY = "viewedNewsIds"; const computeNewsId = (news: NewsData): string => { const contentHash = news.content .split("") .reduce( (hash, char) => (hash << 5) + hash + char.charCodeAt(0), 5381, ) >>> 0; const headlineHash = (news.headline || "null") .split("") .reduce( (hash, char) => (hash << 5) + hash + char.charCodeAt(0), 5381, ) >>> 0; const legacyId = `${news.identifier}-${news.timestamp}-${contentHash.toString(16)}-${headlineHash.toString(16)}`; return news.archive_hash || legacyId; }; export const NewsFeed: React.FC = ({ newsItems }) => { const { t } = useTranslation(); const [showEnglish, setShowEnglish] = useState>({}); const [expanded, setExpanded] = useState>({}); const [currentImageIndex, setCurrentImageIndex] = useState< Record >({}); const [loadingImages, setLoadingImages] = useState>( {}, ); const [searchParams] = useSearchParams(); const isMoe = searchParams.has("moe"); const pfpBaseUrl = import.meta.env.VITE_PFP_BASE_URL; const middlewareBase = import.meta.env.VITE_MIDDLEWARE_BASE_URL; const [initialViewedIds] = useState>(() => { try { const raw = localStorage.getItem(VIEWED_KEY); if (!raw) return new Set(); const parsed = JSON.parse(raw) as Array<{ id: string } | string>; return new Set(parsed.map((e) => (typeof e === "string" ? e : e.id))); } catch { return new Set(); } }); useEffect(() => { try { const raw = localStorage.getItem(VIEWED_KEY); const prev: { id: string; timestamp: number }[] = raw ? (JSON.parse(raw) as Array<{ id: string; timestamp?: number } | string>).map( (e) => (typeof e === "string" ? { id: e, timestamp: 0 } : { id: e.id, timestamp: e.timestamp ?? 0 }), ) : []; const map = new Map(prev.map((e) => [e.id, e.timestamp])); for (const news of newsItems) { const id = computeNewsId(news); if (!map.has(id)) map.set(id, news.timestamp); } const next = [...map.entries()] .map(([id, timestamp]) => ({ id, timestamp })) .sort((a, b) => b.timestamp - a.timestamp) .slice(0, 100); localStorage.setItem(VIEWED_KEY, JSON.stringify(next)); } catch { console.error("Failed to update viewed news items"); } }, [newsItems]); const toggleLanguage = (id: string) => setShowEnglish((prev) => ({ ...prev, [id]: !prev[id] })); const toggleExpand = (id: string) => setExpanded((prev) => ({ ...prev, [id]: !prev[id] })); const changeImage = (id: string, i: number) => { if (currentImageIndex[id] == i) return; setCurrentImageIndex((p) => ({ ...p, [id]: i })); setLoadingImages((p) => ({ ...p, [id]: true })); }; const handleImageLoad = (id: string) => setLoadingImages((p) => ({ ...p, [id]: false })); const PREVIEW_CHAR_LIMIT = 600; useEffect(() => { const initialImageIndex: Record = {}; newsItems.forEach((news) => { initialImageIndex[computeNewsId(news)] = 0; }); setCurrentImageIndex(initialImageIndex); }, [newsItems]); useEffect(() => { const fragment = window.location.hash.slice(1); if (fragment) { const el = document.getElementById(fragment); if (el) { el.scrollIntoView({ behavior: "smooth", block: "start" }); } else { alert("News Post doesn't or no longer exists..."); } } }, [newsItems]); const unviewed = newsItems .filter((n) => !initialViewedIds.has(computeNewsId(n))) .sort((a, b) => b.timestamp - a.timestamp); const viewed = newsItems.filter((n) => initialViewedIds.has(computeNewsId(n))); const nothingNew = unviewed.length === 0 && newsItems.length > 0; const feed: (NewsData | Sentinel)[] = []; if (nothingNew) feed.push({ _sentinel: "nothing-new" }); feed.push(...unviewed); if (unviewed.length > 0 && viewed.length > 0) feed.push({ _sentinel: "caught-up" }); feed.push(...(nothingNew ? newsItems : viewed)); return (
{feed.map((item, idx) => { if ("_sentinel" in item) { const isFirstSentinel = idx === 0; return (
{item._sentinel === t("nothing_new") ? t("nothing_new") : t("caught_up")}
); } const news = item; const date = new Date(news.timestamp * 1000).toLocaleDateString( "ja-JP", { year: "numeric", month: "2-digit", day: "2-digit" }, ); const newsId = computeNewsId(news); const isEnglish = showEnglish[newsId]; const hasTranslation = news.en_headline || news.en_content; const displayHeadline = isEnglish && news.en_headline ? news.en_headline : news.headline; const displayContent = isEnglish && news.en_content ? news.en_content : news.content; const isLong = displayContent.length > PREVIEW_CHAR_LIMIT; const isExpanded = !!expanded[newsId]; const contentToShow = isLong && !isExpanded ? displayContent.slice(0, PREVIEW_CHAR_LIMIT) + "…" : displayContent; return (
{displayHeadline && (

{displayHeadline}

)}

{contentToShow .split(/(\[.*?\]\(.*?\)|https?:\/\/[^\s]+)/g) .map((part, idx) => { const m = part.match(/\[(.*?)\]\((.*?)\)/); const u = part.match(/https?:\/\/[^\s]+/); if (m) return ( {m[1]} ); if (u) return ( {u[0]} ); return part; })}

{isLong && ( )}
{/* Copy Link to Post */} {/* AI Disclaimer */} {news.is_ai_summary && (
{`${t('ai_summary_note')}`}
)} {/* Machine TL Disclaimer */} {hasTranslation && isEnglish && (
{`${t('machine_tl_note')}`}
)} {/* Images */} {news.images.length > 0 && (
{(() => { const idx = currentImageIndex[newsId] || 0; const img = news.images[idx]; return (
{loadingImages[newsId] && (
)} news visual handleImageLoad(newsId)} />
); })()} {news.images.length > 1 && (
{news.images.map((_, idx) => ( ))}
)}
)} {news.url && ( )}
); })}
); };