feat(radar): prepare launch news surface

This commit is contained in:
diegosouzapw
2026-08-09 09:06:10 -03:00
committed by Xiangzhe
parent 6f48ac4bfe
commit d0aff219f5
13 changed files with 705 additions and 106 deletions

View File

@@ -22,7 +22,6 @@ import { HomeProviderTopologySection } from "./HomeProviderTopologySection";
import { shouldShowProviderTopologyOnHome } from "./homeAppearance";
const ProviderQuotaWidget = dynamic(() => import("../home/ProviderQuotaWidget"), { ssr: false });
import type { NewsAnnouncement } from "@/shared/utils/releaseNotes";
type UpdateStep = {
step: string;
@@ -37,7 +36,6 @@ type VersionInfo = {
channel: string;
autoUpdateSupported: boolean;
autoUpdateError?: string | null;
news?: NewsAnnouncement | null;
};
type HomePageClientProps = {
@@ -1047,37 +1045,6 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
</div>
)}
</div>
{/* News Notification Banner */}
{versionInfo?.news && (
<div className="flex min-h-[64px] items-center justify-between rounded-lg border border-border bg-surface px-5 py-4">
<div className="flex min-w-0 items-center gap-4">
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-bg text-text-muted">
<span className="material-symbols-outlined text-[22px] text-primary">
{versionInfo.news.icon || "campaign"}
</span>
</div>
<div className="min-w-0">
<p className="text-sm font-semibold text-text-main">{versionInfo.news.title}</p>
<p className="mt-0.5 max-w-[560px] text-xs leading-relaxed text-text-muted">
{versionInfo.news.message}
</p>
</div>
</div>
{versionInfo.news.link && (
<a
href={versionInfo.news.link}
target="_blank"
rel="noopener noreferrer"
className="ml-4 inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-border bg-bg px-4 py-2 text-xs font-semibold text-text-main transition-colors hover:border-primary/30 hover:text-primary"
>
{versionInfo.news.linkLabel || t("readMore")}
<span className="material-symbols-outlined text-[14px]">arrow_forward</span>
</a>
)}
</div>
)}
</div>
)}

View File

@@ -0,0 +1,119 @@
"use client";
import { useEffect, useState, useSyncExternalStore } from "react";
import { useLocale, useTranslations } from "next-intl";
import {
NEWS_DISMISS_EVENT,
NEWS_DISMISS_STORAGE_KEY,
fetchNewsPayload,
parseDismissedNewsIds,
selectActiveNews,
serializeDismissedNewsIds,
} from "@/shared/utils/releaseNotes";
function subscribeToDismissals(callback: () => void) {
window.addEventListener("storage", callback);
window.addEventListener(NEWS_DISMISS_EVENT, callback);
return () => {
window.removeEventListener("storage", callback);
window.removeEventListener(NEWS_DISMISS_EVENT, callback);
};
}
function readDismissedIds(): string {
try {
return localStorage.getItem(NEWS_DISMISS_STORAGE_KEY) ?? "";
} catch {
return "";
}
}
function getServerDismissedIds(): string {
return "";
}
/**
* Generic, fail-silent reader for the public announcement feed. Fetching the
* static JSON is GET-only and does not send product state or telemetry.
*/
export default function NewsBanner() {
const locale = useLocale();
const t = useTranslations("common");
const [payload, setPayload] = useState<unknown>(null);
const dismissedSnapshot = useSyncExternalStore(
subscribeToDismissals,
readDismissedIds,
getServerDismissedIds
);
const dismissedIds = parseDismissedNewsIds(dismissedSnapshot);
const announcement = selectActiveNews(payload, locale, dismissedIds);
useEffect(() => {
const controller = new AbortController();
void fetchNewsPayload(fetch, controller.signal).then((value) => {
if (value !== null) setPayload(value);
});
return () => controller.abort();
}, []);
if (!announcement) return null;
const dismiss = () => {
dismissedIds.add(announcement.id);
try {
localStorage.setItem(NEWS_DISMISS_STORAGE_KEY, serializeDismissedNewsIds(dismissedIds));
} catch {
// Storage is optional; the next announcement fetch remains functional.
}
window.dispatchEvent(new Event(NEWS_DISMISS_EVENT));
};
return (
<div
role="complementary"
aria-label={announcement.title}
className="mb-4 flex flex-col gap-3 rounded-lg border border-primary/30 bg-primary/5 px-4 py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex min-w-0 items-start gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<span className="material-symbols-outlined text-[22px] text-primary" aria-hidden="true">
{announcement.icon}
</span>
</div>
<div className="min-w-0">
<p className="text-sm font-semibold text-text-main">{announcement.title}</p>
<p className="mt-0.5 text-xs leading-relaxed text-text-muted">{announcement.message}</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-3 self-end sm:self-auto">
{announcement.link && (
<a
href={announcement.link}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 whitespace-nowrap rounded-lg bg-primary px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:brightness-110"
>
{announcement.linkLabel ?? announcement.title}
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
open_in_new
</span>
</a>
)}
<button
type="button"
onClick={dismiss}
aria-label={t("dismissNotification")}
className="text-text-muted transition-colors hover:text-text-main"
>
<span className="material-symbols-outlined text-[18px]" aria-hidden="true">
close
</span>
</button>
</div>
</div>
);
}

View File

@@ -1,39 +1,39 @@
"use client";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { useLocale, useTranslations } from "next-intl";
import { Button } from "@/shared/components";
import {
NEWS_JSON_URL,
parseActiveNewsPayload,
fetchNewsPayload,
listActiveNews,
type NewsAnnouncement,
} from "@/shared/utils/releaseNotes";
export default function NewsViewer() {
const locale = useLocale();
const t = useTranslations("changelogPage");
const [news, setNews] = useState<NewsAnnouncement | null>(null);
const [news, setNews] = useState<NewsAnnouncement[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
async function fetchNews() {
try {
const res = await fetch(NEWS_JSON_URL, { cache: "no-store" });
if (res.ok) {
const data = await res.json();
setNews(parseActiveNewsPayload(data));
} else {
setError(true);
const controller = new AbortController();
void fetchNewsPayload(fetch, controller.signal)
.then((payload) => {
if (payload === null) {
if (!controller.signal.aborted) setError(true);
return;
}
} catch (err) {
console.error("Failed to fetch news:", err);
setError(true);
} finally {
setLoading(false);
}
}
fetchNews();
}, []);
setNews(listActiveNews(payload, locale));
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, [locale]);
if (loading) {
return (
@@ -48,7 +48,7 @@ export default function NewsViewer() {
if (error) {
return (
<div className="flex flex-col items-center justify-center py-20 text-text-muted">
<span className="material-symbols-outlined text-[48px] text-red-500/50 mb-4">
<span className="material-symbols-outlined mb-4 text-[48px] text-red-500/50">
error_outline
</span>
<p>{t("announcementsLoadFailed")}</p>
@@ -56,10 +56,10 @@ export default function NewsViewer() {
);
}
if (!news || !news.active) {
if (news.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 text-text-muted">
<span className="material-symbols-outlined text-[48px] opacity-50 mb-4">
<span className="material-symbols-outlined mb-4 text-[48px] opacity-50">
notifications_off
</span>
<p>{t("noAnnouncements")}</p>
@@ -68,30 +68,37 @@ export default function NewsViewer() {
}
return (
<div className="p-8">
<div className="flex flex-col gap-6 border-l-4 border-primary pl-5 md:flex-row md:items-center md:pl-6">
<div className="size-14 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
<span className="material-symbols-outlined text-[30px] text-primary">
{news.icon || "campaign"}
</span>
</div>
<div className="flex-1">
<h2 className="text-xl font-bold text-text-main mb-2">{news.title}</h2>
<p className="text-sm text-text-muted leading-relaxed max-w-2xl">{news.message}</p>
</div>
{news.link && (
<div className="shrink-0 md:ml-auto">
<a href={news.link} target="_blank" rel="noopener noreferrer">
<Button variant="primary" className="gap-2">
{news.linkLabel || t("learnMore")}
<span className="material-symbols-outlined text-[18px]">arrow_forward</span>
</Button>
</a>
<div className="space-y-8 p-8">
{news.map((announcement) => (
<article
key={announcement.id}
className="flex flex-col gap-6 border-l-4 border-primary pl-5 md:flex-row md:items-center md:pl-6"
>
<div className="flex size-14 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<span className="material-symbols-outlined text-[30px] text-primary">
{announcement.icon}
</span>
</div>
)}
</div>
<div className="flex-1">
<h2 className="mb-2 text-xl font-bold text-text-main">{announcement.title}</h2>
<p className="max-w-2xl text-sm leading-relaxed text-text-muted">
{announcement.message}
</p>
</div>
{announcement.link && (
<div className="shrink-0 md:ml-auto">
<a href={announcement.link} target="_blank" rel="noopener noreferrer">
<Button variant="primary" className="gap-2">
{announcement.linkLabel ?? t("learnMore")}
<span className="material-symbols-outlined text-[18px]">arrow_forward</span>
</Button>
</a>
</div>
)}
</article>
))}
</div>
);
}

View File

@@ -4,6 +4,7 @@ import { getSettings } from "@/lib/localDb";
import HomePageClient from "../dashboard/HomePageClient";
import BootstrapBanner from "../dashboard/BootstrapBanner";
import KimiSponsorBanner from "../dashboard/KimiSponsorBanner";
import NewsBanner from "../dashboard/NewsBanner";
export const dynamic = "force-dynamic";
@@ -18,6 +19,7 @@ export default async function HomePage() {
<>
{isBootstrapped && <BootstrapBanner />}
<KimiSponsorBanner />
<NewsBanner />
<HomePageClient machineId={machineId} />
</>
);

View File

@@ -6,34 +6,242 @@ export const CHANGELOG_RAW_URL =
"https://raw.githubusercontent.com/diegosouzapw/OmniRoute/main/CHANGELOG.md";
export const CHANGELOG_GITHUB_URL =
"https://github.com/diegosouzapw/OmniRoute/blob/main/CHANGELOG.md";
export const NEWS_DISMISS_STORAGE_KEY = "omniroute-news-dismissed-v2";
export const NEWS_DISMISS_EVENT = "omniroute:news-dismissed";
const activeNewsSchema = z.object({
active: z.literal(true),
title: z.string().trim().min(1).max(120),
message: z.string().trim().min(1).max(600),
link: z.string().url().optional(),
linkLabel: z.string().trim().min(1).max(80).optional(),
icon: z
.string()
.trim()
.regex(/^[a-z0-9_]+$/)
.optional(),
const NEWS_ID_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,78}[a-z0-9])?$/;
const LOCALE_PATTERN = /^[a-z]{2,3}(?:-[A-Z][a-z]{3})?(?:-[A-Z]{2}|-[0-9]{3})?$/;
const MAX_DISMISSED_IDS = 50;
const newsIconSchema = z.enum(["campaign", "celebration", "info", "new_releases", "radar"]);
const localizedTextSchema = z
.object({
title: z.string().trim().min(1).max(120),
message: z.string().trim().min(1).max(600),
linkLabel: z.string().trim().min(1).max(80).optional(),
})
.strict();
const httpsUrlSchema = z
.string()
.url()
.max(500)
.refine((value) => {
const url = new URL(value);
return url.protocol === "https:" && !url.username && !url.password;
}, "Announcement links must use HTTPS without embedded credentials");
const localizedTextMapSchema = z.record(localizedTextSchema).superRefine((value, context) => {
if (!value.en) {
context.addIssue({ code: z.ZodIssueCode.custom, message: "English copy is required" });
}
for (const locale of Object.keys(value)) {
if (!LOCALE_PATTERN.test(locale)) {
context.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid locale: ${locale}`,
});
}
}
});
const inactiveNewsSchema = z
const newsFeedItemSchema = z
.object({
id: z.string().regex(NEWS_ID_PATTERN),
active: z.boolean(),
publishedAt: z.string().datetime({ offset: true }),
text: localizedTextMapSchema,
link: httpsUrlSchema.optional(),
icon: newsIconSchema,
})
.strict();
const newsFeedSchema = z
.object({
schemaVersion: z.literal(2),
items: z.array(newsFeedItemSchema).max(50),
})
.strict()
.superRefine(({ items }, context) => {
const ids = new Set<string>();
for (const [index, item] of items.entries()) {
if (ids.has(item.id)) {
context.addIssue({
code: z.ZodIssueCode.custom,
message: `Duplicate announcement id: ${item.id}`,
path: ["items", index, "id"],
});
}
ids.add(item.id);
}
});
const legacyActiveNewsSchema = z
.object({
active: z.literal(true),
title: z.string().trim().min(1).max(120),
message: z.string().trim().min(1).max(600),
link: httpsUrlSchema.optional(),
linkLabel: z.string().trim().min(1).max(80).optional(),
icon: newsIconSchema.optional(),
})
.strict();
const legacyInactiveNewsSchema = z
.object({
active: z.literal(false),
})
.passthrough();
const newsPayloadSchema = z.discriminatedUnion("active", [activeNewsSchema, inactiveNewsSchema]);
const legacyNewsSchema = z.discriminatedUnion("active", [
legacyActiveNewsSchema,
legacyInactiveNewsSchema,
]);
export type NewsAnnouncement = z.infer<typeof activeNewsSchema>;
export type NewsFeedItem = z.infer<typeof newsFeedItemSchema>;
export type NewsIcon = z.infer<typeof newsIconSchema>;
export type NewsAnnouncement = {
id: string;
active: true;
publishedAt: string;
title: string;
message: string;
link?: string;
linkLabel?: string;
icon: NewsIcon;
};
type NewsFetchResponse = Pick<Response, "json" | "ok">;
type NewsFetch = (url: string, init: RequestInit) => Promise<NewsFetchResponse>;
export async function fetchNewsPayload(
fetchNews: NewsFetch = fetch,
signal?: AbortSignal
): Promise<unknown | null> {
try {
const response = await fetchNews(NEWS_JSON_URL, {
cache: "no-store",
credentials: "omit",
referrerPolicy: "no-referrer",
signal,
});
return response.ok ? await response.json() : null;
} catch {
return null;
}
}
function stableHash(value: string): string {
let hash = 0x811c9dc5;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0).toString(16).padStart(8, "0");
}
function normalizeLegacyNews(payload: unknown): NewsFeedItem[] {
const parsed = legacyNewsSchema.safeParse(payload);
if (!parsed.success || !parsed.data.active) return [];
const item = parsed.data;
return [
{
id: `legacy-${stableHash(`${item.title}\n${item.message}\n${item.link ?? ""}`)}`,
active: true,
publishedAt: "1970-01-01T00:00:00.000Z",
text: {
en: {
title: item.title,
message: item.message,
...(item.linkLabel ? { linkLabel: item.linkLabel } : {}),
},
},
...(item.link ? { link: item.link } : {}),
icon: item.icon ?? "campaign",
},
];
}
export function parseNewsPayload(payload: unknown): NewsFeedItem[] {
const feed = newsFeedSchema.safeParse(payload);
if (feed.success) return feed.data.items;
return normalizeLegacyNews(payload);
}
function resolveLocalizedText(item: NewsFeedItem, locale: string) {
const normalizedLocale = locale.trim().replace("_", "-");
const exactKey = Object.keys(item.text).find(
(key) => key.toLowerCase() === normalizedLocale.toLowerCase()
);
if (exactKey) return item.text[exactKey];
const language = normalizedLocale.split("-")[0]?.toLowerCase();
const languageKey = Object.keys(item.text).find((key) => key.toLowerCase() === language);
return (languageKey && item.text[languageKey]) || item.text.en;
}
export function listActiveNews(
payload: unknown,
locale = "en",
now = new Date()
): NewsAnnouncement[] {
const nowMs = now.getTime();
if (!Number.isFinite(nowMs)) return [];
return parseNewsPayload(payload)
.filter((item) => item.active && Date.parse(item.publishedAt) <= nowMs)
.sort(
(left, right) =>
Date.parse(right.publishedAt) - Date.parse(left.publishedAt) ||
left.id.localeCompare(right.id)
)
.map((item) => {
const text = resolveLocalizedText(item, locale);
return {
id: item.id,
active: true as const,
publishedAt: item.publishedAt,
title: text.title,
message: text.message,
...(item.link ? { link: item.link } : {}),
...(text.linkLabel ? { linkLabel: text.linkLabel } : {}),
icon: item.icon,
};
});
}
export function selectActiveNews(
payload: unknown,
locale = "en",
dismissedIds: ReadonlySet<string> = new Set(),
now = new Date()
): NewsAnnouncement | null {
return listActiveNews(payload, locale, now).find((item) => !dismissedIds.has(item.id)) ?? null;
}
export function parseActiveNewsPayload(payload: unknown): NewsAnnouncement | null {
const parsed = newsPayloadSchema.safeParse(payload);
if (!parsed.success || parsed.data.active !== true) return null;
return parsed.data;
return selectActiveNews(payload, "en");
}
export function parseDismissedNewsIds(raw: string | null): Set<string> {
if (!raw) return new Set();
try {
const value: unknown = JSON.parse(raw);
if (!Array.isArray(value)) return new Set();
const ids = value.filter(
(id): id is string => typeof id === "string" && NEWS_ID_PATTERN.test(id)
);
return new Set(ids.slice(-MAX_DISMISSED_IDS));
} catch {
return new Set();
}
}
export function serializeDismissedNewsIds(ids: Iterable<string>): string {
const sanitized = [...new Set(ids)].filter((id) => NEWS_ID_PATTERN.test(id));
return JSON.stringify(sanitized.slice(-MAX_DISMISSED_IDS));
}
export function getLatestChangelogMarkdown(markdown: string, limit = 10): string {