mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 09:42:15 +03:00
feat(radar): prepare launch news surface (#9926)
* feat(radar): prepare launch news surface * docs(changelog): add Radar launch news fragment --------- Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
0c17c7219b
commit
5a4e968cc1
19
README.md
19
README.md
@@ -491,6 +491,25 @@ Pix copia-e-cola:
|
||||
|
||||
<br/>
|
||||
|
||||
## 📡 OmniRoute Radar
|
||||
|
||||
The main free-tier headline remains **~1.53B tokens/month** from the documented,
|
||||
pool-deduplicated catalog above. Temporary provider signup credits can separately lift the first
|
||||
month to **~2.15B**. Radar is an optional, signed catalog overlay for people who want fresher
|
||||
free-model availability between OmniRoute releases; the community catalog and every existing free
|
||||
feature remain free.
|
||||
|
||||
Supporters can receive the live catalog and additional provider opportunities. Its separate,
|
||||
mutable ceiling is **approximately 3B tokens/month at most**, depending on provider availability.
|
||||
That ceiling is not a guarantee: providers can change quotas, eligibility, models, or regions at
|
||||
any time.
|
||||
|
||||
Radar is opt-in and GET-only. The OmniRoute client does not upload prompts, traffic, provider
|
||||
configuration, usage telemetry, or local announcement-dismiss state. Learn about eligibility and
|
||||
the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute.online/planos)**.
|
||||
|
||||
<br/>
|
||||
|
||||
<div align="center">
|
||||
|
||||
## ✨ What's New
|
||||
|
||||
1
changelog.d/features/9926-radar-launch-news.md
Normal file
1
changelog.d/features/9926-radar-launch-news.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(radar):** add a localized public news feed and dismissible dashboard launch banner, with the Radar announcement staged inactive for a separately authorized launch ([#9926](https://github.com/diegosouzapw/OmniRoute/pull/9926))
|
||||
@@ -102,7 +102,7 @@ OmniRoute/
|
||||
| **.gitleaks.toml** | gitleaks secret-scan ruleset |
|
||||
| **.zizmor.yml** | zizmor GitHub-Actions security-lint config |
|
||||
| **socket.yml** | Socket.dev supply-chain config |
|
||||
| **news.json** | In-app release-notes feed (read by `src/shared/utils/releaseNotes.ts`) |
|
||||
| **news.json** | Localized v2 announcement feed; Radar launch item ships inactive |
|
||||
| **flake.nix** / **flake.lock** | Nix dev-shell definition + lock |
|
||||
| **.env** | Local secrets (gitignored — generated from `.env.example`) |
|
||||
|
||||
@@ -256,6 +256,7 @@ src/
|
||||
| `utils/circuitBreaker.ts` | Provider circuit breaker (see `docs/architecture/RESILIENCE_GUIDE.md`) |
|
||||
| `utils/apiAuth.ts` | API key validation, scope checking |
|
||||
| `utils/fetchTimeout.ts` | Timeout/abort wrappers for upstream fetch |
|
||||
| `utils/releaseNotes.ts` | Closed v2/legacy announcement parser, localization and ID dismissal |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -43,6 +43,29 @@ or external integration is currently available.
|
||||
|
||||
---
|
||||
|
||||
## Public announcement reader
|
||||
|
||||
The generic announcement reader is separate from the Radar feature flag. The dashboard Home and
|
||||
Changelog viewer fetch the repository's public `news.json` through a plain `GET` to
|
||||
`NEWS_JSON_URL` (`src/shared/utils/releaseNotes.ts`). They send no Radar setting, prompt, provider
|
||||
configuration, usage record, or local dismissal state.
|
||||
|
||||
`news.json` uses the closed v2 schema implemented by `parseNewsPayload()`:
|
||||
|
||||
- `schemaVersion: 2` and a bounded `items[]` collection;
|
||||
- stable, unique announcement `id` values;
|
||||
- explicit `active` and ISO `publishedAt` fields;
|
||||
- required English copy with optional localized copy;
|
||||
- optional credential-free HTTPS links and an allowlisted icon;
|
||||
- newest-active-first selection, locale fallback to English, and per-ID local dismissal.
|
||||
|
||||
The parser temporarily accepts the former singular `{ active, title, message, ... }` shape so
|
||||
older forks can migrate without a broken Changelog view. Invalid feeds are inert. The Radar launch
|
||||
entry ships with `active: false`; changing it to `true` is a separate post-merge, post-deploy
|
||||
release action and does not change `RADAR_ENABLED` or the independent feed-sync opt-in.
|
||||
|
||||
---
|
||||
|
||||
## Flag: `RADAR_ENABLED` (default off)
|
||||
|
||||
Radar is gated end-to-end by the `RADAR_ENABLED` feature flag
|
||||
|
||||
@@ -65,6 +65,7 @@ directly from anywhere — CI can only stage; only the owner's 2FA releases.
|
||||
as the default reflex (minutes, reversible); `npm unpublish` only inside the 72h/no-dependents
|
||||
window and never as the first move. Docker: never rewrite a version tag — rollback is
|
||||
repointing `latest` to the last good digest.
|
||||
|
||||
## Hotfix Fast-Lane (label `hotfix`)
|
||||
|
||||
A PR labeled `hotfix` skips the heavy CI matrix (9-shard E2E, coverage ratchet,
|
||||
@@ -276,6 +277,22 @@ Deploy skills use the light rsync flow — no `npm pack`, no `npm i -g`:
|
||||
- [ ] Open milestone for next version
|
||||
- [ ] If critical: pin discussion or post in `news.json` for in-app banner
|
||||
|
||||
### Radar public-launch gate
|
||||
|
||||
The Radar announcement is intentionally committed with `active: false`. Activation is a separate
|
||||
change after every item below is evidenced:
|
||||
|
||||
- [ ] All stacked Radar PRs are merged and the release-tip CI is green
|
||||
- [ ] Deploy and smoke the OSS Radar routes with `RADAR_ENABLED` still off by default
|
||||
- [ ] Smoke `GET /planos`, `/termos`, `/privacidade`, and `/reembolso` on the named Radar host
|
||||
- [ ] Record operator identity/contact/address and owner-approved legal review in the private service
|
||||
- [ ] Exercise Stripe Checkout and the signed webhook in test mode only
|
||||
- [ ] Exercise one encrypted transactional-email delivery with the approved sender/domain
|
||||
- [ ] Prove backup restore and one supervised, budget-capped research run
|
||||
- [ ] Approve the BRL/PIX review policy before accepting donation evidence
|
||||
- [ ] Enable public Checkout only after the preceding gates, then activate the new `news.json` ID
|
||||
- [ ] Verify the Home banner uses localized copy and a new ID reappears after an older ID is dismissed
|
||||
|
||||
## Embedded Services smoke (v3.8.4+)
|
||||
|
||||
Before shipping any release that includes embedded services changes, verify:
|
||||
|
||||
@@ -1292,6 +1292,11 @@ self-hosted or forked feed / supporter-key flow instead of the default OmniRoute
|
||||
Radar service. See [docs/frameworks/RADAR.md](../frameworks/RADAR.md) for the full
|
||||
module doc.
|
||||
|
||||
The generic Home/Changelog announcement reader is not configured by an environment
|
||||
variable and does not depend on the RADAR_ENABLED feature flag. It reads the public repository
|
||||
`news.json` URL declared in `src/shared/utils/releaseNotes.ts` by
|
||||
GET only; dismissal IDs remain in browser local storage.
|
||||
|
||||
| Variable | Default | Source File | Description |
|
||||
| -------------------------------- | --------------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------ |
|
||||
| `RADAR_FEED_URL` | `https://radar.omniroute.online` | `src/lib/radar/{sync,referralsSync,offersSync,intelSync}.ts` | Base URL shared by the separately signed catalog, referrals, supporter-offers, and Intel feeds. Override to point at a self-hosted or forked service. |
|
||||
|
||||
47
news.json
47
news.json
@@ -1,8 +1,43 @@
|
||||
{
|
||||
"active": false,
|
||||
"title": "Novidade no Omniverse",
|
||||
"message": "Está lançado hoje o tOmni, o terminal interativo múltiplo para Agentes de AI! Experimente a nova interface focada em produtividade para desenvolvedores.",
|
||||
"link": "https://github.com/diegosouzapw/tOmni",
|
||||
"linkLabel": "Conhecer o tOmni",
|
||||
"icon": "campaign"
|
||||
"schemaVersion": 2,
|
||||
"items": [
|
||||
{
|
||||
"id": "radar-launch-2026-08",
|
||||
"active": false,
|
||||
"publishedAt": "2026-08-09T00:00:00.000Z",
|
||||
"text": {
|
||||
"en": {
|
||||
"title": "OmniRoute Radar",
|
||||
"message": "An opt-in, GET-only free-model catalog overlay with no telemetry from the OmniRoute client.",
|
||||
"linkLabel": "Learn about Radar"
|
||||
},
|
||||
"pt-BR": {
|
||||
"title": "OmniRoute Radar",
|
||||
"message": "Um catálogo opcional de modelos gratuitos, somente GET e sem telemetria enviada pelo cliente OmniRoute.",
|
||||
"linkLabel": "Conheça o Radar"
|
||||
}
|
||||
},
|
||||
"link": "https://radar.omniroute.online/planos",
|
||||
"icon": "radar"
|
||||
},
|
||||
{
|
||||
"id": "tomni-launch-2026-07",
|
||||
"active": false,
|
||||
"publishedAt": "2026-07-01T00:00:00.000Z",
|
||||
"text": {
|
||||
"en": {
|
||||
"title": "New in the Omniverse",
|
||||
"message": "tOmni is an interactive multi-agent terminal focused on developer productivity.",
|
||||
"linkLabel": "Meet tOmni"
|
||||
},
|
||||
"pt-BR": {
|
||||
"title": "Novidade no Omniverse",
|
||||
"message": "O tOmni é um terminal interativo para múltiplos agentes, focado na produtividade de desenvolvedores.",
|
||||
"linkLabel": "Conhecer o tOmni"
|
||||
}
|
||||
},
|
||||
"link": "https://github.com/diegosouzapw/tOmni",
|
||||
"icon": "campaign"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
@@ -1049,37 +1047,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 || "Ler Mais"}
|
||||
<span className="material-symbols-outlined text-[14px]">arrow_forward</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
119
src/app/(dashboard)/dashboard/NewsBanner.tsx
Normal file
119
src/app/(dashboard)/dashboard/NewsBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
33
tests/unit/news-feed-contract.test.ts
Normal file
33
tests/unit/news-feed-contract.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
const root = new URL("../../", import.meta.url);
|
||||
|
||||
test("news.json ships Radar inactive in the localized v2 feed without commercial details", async () => {
|
||||
const source = await readFile(new URL("news.json", root), "utf8");
|
||||
const payload = JSON.parse(source);
|
||||
const radar = payload.items.find((item: { id?: string }) => item.id === "radar-launch-2026-08");
|
||||
|
||||
assert.equal(payload.schemaVersion, 2);
|
||||
assert.equal(radar.active, false);
|
||||
assert.equal(radar.link, "https://radar.omniroute.online/planos");
|
||||
assert.match(radar.text.en.message, /opt-in/i);
|
||||
assert.match(radar.text.en.message, /GET-only/i);
|
||||
assert.match(radar.text.en.message, /no telemetry/i);
|
||||
assert.doesNotMatch(source, /R\$|US\$|coupon|cupom|discount|desconto/i);
|
||||
});
|
||||
|
||||
test("the generic banner is ID-dismissable and independent from the Radar feature flag", async () => {
|
||||
const source = await readFile(
|
||||
new URL("src/app/(dashboard)/dashboard/NewsBanner.tsx", root),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
assert.match(source, /selectActiveNews/);
|
||||
assert.match(source, /parseDismissedNewsIds/);
|
||||
assert.match(source, /localStorage/);
|
||||
assert.match(source, /announcement\.id/);
|
||||
assert.doesNotMatch(source, /RADAR_ENABLED/);
|
||||
assert.doesNotMatch(source, /method:\s*["']POST["']/);
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
|
||||
|
||||
const releaseNotes = await import("../../src/shared/utils/releaseNotes.ts");
|
||||
|
||||
test("parseActiveNewsPayload returns only valid active announcements", () => {
|
||||
test("parseActiveNewsPayload keeps the legacy singular contract", () => {
|
||||
assert.deepEqual(
|
||||
releaseNotes.parseActiveNewsPayload({
|
||||
active: true,
|
||||
@@ -14,7 +14,9 @@ test("parseActiveNewsPayload returns only valid active announcements", () => {
|
||||
icon: "campaign",
|
||||
}),
|
||||
{
|
||||
id: "legacy-8d301d21",
|
||||
active: true,
|
||||
publishedAt: "1970-01-01T00:00:00.000Z",
|
||||
title: "Launch",
|
||||
message: "A short announcement",
|
||||
link: "https://github.com/diegosouzapw/tOmni",
|
||||
@@ -35,6 +37,167 @@ test("parseActiveNewsPayload returns only valid active announcements", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("parseNewsPayload validates the closed v2 feed and preserves inactive entries", () => {
|
||||
const payload = {
|
||||
schemaVersion: 2,
|
||||
items: [
|
||||
{
|
||||
id: "radar-launch-2026-08",
|
||||
active: false,
|
||||
publishedAt: "2026-08-09T12:00:00.000Z",
|
||||
text: {
|
||||
en: { title: "Radar", message: "Opt-in catalog", linkLabel: "Learn more" },
|
||||
"pt-BR": { title: "Radar", message: "Catálogo opt-in", linkLabel: "Saiba mais" },
|
||||
},
|
||||
link: "https://radar.omniroute.online/planos",
|
||||
icon: "radar",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
assert.deepEqual(releaseNotes.parseNewsPayload(payload), payload.items);
|
||||
assert.deepEqual(releaseNotes.parseNewsPayload({ ...payload, unexpected: true }), []);
|
||||
assert.deepEqual(
|
||||
releaseNotes.parseNewsPayload({
|
||||
...payload,
|
||||
items: [{ ...payload.items[0], link: "http://radar.omniroute.online/planos" }],
|
||||
}),
|
||||
[]
|
||||
);
|
||||
assert.deepEqual(
|
||||
releaseNotes.parseNewsPayload({
|
||||
...payload,
|
||||
items: [...payload.items, payload.items[0]],
|
||||
}),
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
test("listActiveNews localizes, filters future entries and sorts newest first", () => {
|
||||
const payload = {
|
||||
schemaVersion: 2,
|
||||
items: [
|
||||
{
|
||||
id: "older",
|
||||
active: true,
|
||||
publishedAt: "2026-08-08T12:00:00.000Z",
|
||||
text: { en: { title: "Older", message: "English" } },
|
||||
icon: "info",
|
||||
},
|
||||
{
|
||||
id: "newer",
|
||||
active: true,
|
||||
publishedAt: "2026-08-09T12:00:00.000Z",
|
||||
text: {
|
||||
en: { title: "Newer", message: "English" },
|
||||
"pt-BR": { title: "Mais recente", message: "Português" },
|
||||
},
|
||||
icon: "campaign",
|
||||
},
|
||||
{
|
||||
id: "future",
|
||||
active: true,
|
||||
publishedAt: "2026-08-11T12:00:00.000Z",
|
||||
text: { en: { title: "Future", message: "Not published yet" } },
|
||||
icon: "campaign",
|
||||
},
|
||||
{
|
||||
id: "inactive",
|
||||
active: false,
|
||||
publishedAt: "2026-08-09T13:00:00.000Z",
|
||||
text: { en: { title: "Inactive", message: "Hidden" } },
|
||||
icon: "campaign",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const news = releaseNotes.listActiveNews(payload, "pt-BR", new Date("2026-08-10T00:00:00.000Z"));
|
||||
assert.deepEqual(
|
||||
news.map((item: { id: string; title: string; message: string }) => [
|
||||
item.id,
|
||||
item.title,
|
||||
item.message,
|
||||
]),
|
||||
[
|
||||
["newer", "Mais recente", "Português"],
|
||||
["older", "Older", "English"],
|
||||
]
|
||||
);
|
||||
assert.equal(
|
||||
releaseNotes.listActiveNews(payload, "pt-PT", new Date("2026-08-10T00:00:00.000Z"))[0]?.message,
|
||||
"English"
|
||||
);
|
||||
});
|
||||
|
||||
test("selectActiveNews skips dismissed ids while retaining new announcements", () => {
|
||||
const payload = {
|
||||
schemaVersion: 2,
|
||||
items: [
|
||||
{
|
||||
id: "newer",
|
||||
active: true,
|
||||
publishedAt: "2026-08-09T12:00:00.000Z",
|
||||
text: { en: { title: "Newer", message: "English" } },
|
||||
icon: "campaign",
|
||||
},
|
||||
{
|
||||
id: "older",
|
||||
active: true,
|
||||
publishedAt: "2026-08-08T12:00:00.000Z",
|
||||
text: { en: { title: "Older", message: "English" } },
|
||||
icon: "campaign",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
releaseNotes.selectActiveNews(
|
||||
payload,
|
||||
"en",
|
||||
new Set(["newer"]),
|
||||
new Date("2026-08-10T00:00:00.000Z")
|
||||
)?.id,
|
||||
"older"
|
||||
);
|
||||
});
|
||||
|
||||
test("dismissed announcement ids are sanitized, deduplicated and bounded", () => {
|
||||
assert.deepEqual(
|
||||
[...releaseNotes.parseDismissedNewsIds('["valid-id","valid-id","<script>",4]')],
|
||||
["valid-id"]
|
||||
);
|
||||
assert.equal(releaseNotes.parseDismissedNewsIds("not-json").size, 0);
|
||||
|
||||
const ids = Array.from({ length: 60 }, (_, index) => `announcement-${index}`);
|
||||
const serialized = releaseNotes.serializeDismissedNewsIds(ids);
|
||||
assert.equal(JSON.parse(serialized).length, 50);
|
||||
assert.deepEqual(JSON.parse(serialized).slice(-2), ["announcement-58", "announcement-59"]);
|
||||
});
|
||||
|
||||
test("fetchNewsPayload is GET-only and keeps network failures inert", async () => {
|
||||
let captured: { url: string; init: RequestInit } | undefined;
|
||||
const payload = { schemaVersion: 2, items: [] };
|
||||
const result = await releaseNotes.fetchNewsPayload(async (url: string, init: RequestInit) => {
|
||||
captured = { url, init };
|
||||
return { ok: true, json: async () => payload };
|
||||
});
|
||||
|
||||
assert.deepEqual(result, payload);
|
||||
assert.equal(captured?.url, releaseNotes.NEWS_JSON_URL);
|
||||
assert.equal(captured?.init.method, undefined);
|
||||
assert.equal(captured?.init.credentials, "omit");
|
||||
assert.equal(
|
||||
await releaseNotes.fetchNewsPayload(async () => {
|
||||
throw new Error("offline");
|
||||
}),
|
||||
null
|
||||
);
|
||||
assert.equal(
|
||||
await releaseNotes.fetchNewsPayload(async () => ({ ok: false, json: async () => payload })),
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
test("getLatestChangelogMarkdown keeps the title and the ten latest releases", () => {
|
||||
const versions = Array.from({ length: 12 }, (_, index) => {
|
||||
const version = `1.0.${12 - index}`;
|
||||
|
||||
Reference in New Issue
Block a user