Skip to content
7 changes: 7 additions & 0 deletions resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1044,5 +1044,12 @@
"description": "(ALPHA)",
"login_required": "Login to play ranked!",
"must_login": "You must be logged in to play ranked matchmaking."
},
"news_box": {
"tournament": "TOURNAMENT",
"tutorial": "TUTORIAL",
"news": "NEWS",
"dismiss": "Dismiss",
"go_to_item": "Go to item {num}"
}
}
23 changes: 23 additions & 0 deletions resources/news.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
[
{
"id": "clan-tournament-spring-2026",
"title": "Upcoming: Spring Clan Tournament",
"description": "2v2 clan battles — Sign up on Discord before April 12",
"url": "https://discord.gg/openfront",
"type": "tournament"
},
{
"id": "clan-tournaments-2026",
"title": "Clan Tournaments",
"description": "Join a clan and compete in weekly tournaments on Discord!",
"url": "https://discord.gg/openfront",
"type": "tournament"
},
{
"id": "tutorial-2026",
"title": "New Player Tutorial",
"description": "Learn the basics of OpenFront in this video guide",
"url": "https://www.youtube.com/watch?v=EN2oOog3pSs",
"type": "tutorial"
}
]
25 changes: 25 additions & 0 deletions src/client/Api.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import newsItemsFallback from "resources/news.json";
import { z } from "zod";
import type { NewsItem } from "../core/ApiSchemas";
import {
ClanLeaderboardResponse,
ClanLeaderboardResponseSchema,
NewsItemSchema,
PlayerProfile,
PlayerProfileSchema,
RankedLeaderboardResponse,
Expand Down Expand Up @@ -263,3 +266,25 @@ export async function fetchPlayerLeaderboard(
return false;
}
}

export async function getNews(): Promise<NewsItem[]> {
try {
const res = await fetch(`${getApiBase()}/news.json`, {
headers: { Accept: "application/json" },
});
if (res.status !== 200) {
console.warn("getNews: unexpected status", res.status);
return newsItemsFallback as NewsItem[];
}
const json = await res.json();
const parsed = z.array(NewsItemSchema).safeParse(json);
if (!parsed.success) {
console.warn("getNews: Zod validation failed", parsed.error);
return newsItemsFallback as NewsItem[];
}
return parsed.data;
} catch (err) {
console.warn("getNews: request failed, using fallback", err);
return newsItemsFallback as NewsItem[];
}
}
180 changes: 180 additions & 0 deletions src/client/components/NewsBox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { LitElement, html, nothing } from "lit";
import { customElement, state } from "lit/decorators.js";
import type { NewsItem } from "../../core/ApiSchemas";
import { getNews } from "../Api";
import { translateText } from "../Utils";

export type { NewsItem };

const DISMISSED_NEWS_KEY = "dismissedNewsItems";
const CYCLE_INTERVAL_MS = 5000;

function getDismissedIds(): Set<string> {
const raw = localStorage.getItem(DISMISSED_NEWS_KEY);
if (raw) return new Set(JSON.parse(raw));
return new Set();
}

function saveDismissedIds(ids: Set<string>): void {
localStorage.setItem(DISMISSED_NEWS_KEY, JSON.stringify([...ids]));
}

export function getVisibleNewsItems(items: NewsItem[]): NewsItem[] {
const dismissed = getDismissedIds();
return items.filter((item) => !dismissed.has(item.id));
}

const typeLabelKeys: Record<NewsItem["type"], string> = {
tournament: "news_box.tournament",
tutorial: "news_box.tutorial",
announcement: "news_box.news",
};

const typeLabelColors: Record<NewsItem["type"], string> = {
tournament: "bg-amber-500/20 text-amber-300",
tutorial: "bg-sky-500/20 text-sky-300",
announcement: "bg-emerald-500/20 text-emerald-300",
};

@customElement("news-box")
export class NewsBox extends LitElement {
@state() private items: NewsItem[] = [];
@state() private activeIndex = 0;
private cycleTimer: ReturnType<typeof setInterval> | null = null;

createRenderRoot() {
return this;
}

connectedCallback() {
super.connectedCallback();
this.loadNews();
}

private async loadNews() {
try {
const allItems = await getNews();
// Reset stale dismissed list when all items would be hidden
const visible = getVisibleNewsItems(allItems);
if (visible.length === 0 && allItems.length > 0) {
localStorage.removeItem(DISMISSED_NEWS_KEY);
this.items = allItems;
} else {
this.items = visible;
}
this.startCycle();
} catch (e) {
console.error(e);
}
}

disconnectedCallback() {
super.disconnectedCallback();
this.stopCycle();
}

private startCycle() {
this.stopCycle();
if (this.items.length > 1) {
this.cycleTimer = setInterval(() => {
this.activeIndex = (this.activeIndex + 1) % this.items.length;
}, CYCLE_INTERVAL_MS);
}
}

private stopCycle() {
if (this.cycleTimer !== null) {
clearInterval(this.cycleTimer);
this.cycleTimer = null;
}
}

private dismiss(id: string) {
const dismissed = getDismissedIds();
dismissed.add(id);
saveDismissedIds(dismissed);
this.items = this.items.filter((item) => item.id !== id);
if (this.activeIndex >= this.items.length) {
this.activeIndex = 0;
}
this.startCycle();
}

private goTo(index: number) {
this.activeIndex = index;
this.startCycle();
}

render() {
if (this.items.length === 0) return nothing;

const item = this.items[this.activeIndex];

return html`
<div
class="px-2 py-2 bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)] border-y border-white/10 lg:border-y-0 lg:rounded-xl lg:p-3"
>
<div class="flex items-center gap-3">
<span
class="shrink-0 text-[10px] font-bold tracking-wider px-2 py-0.5 rounded ${typeLabelColors[
item.type
]}"
>${translateText(typeLabelKeys[item.type])}</span
>
<div class="flex-1 min-w-0">
${item.url
? html`<a
href="${item.url}"
target="_blank"
rel="noopener noreferrer"
class="text-sm font-medium text-white hover:text-blue-300 transition-colors truncate block"
>${item.title}</a
>`
: html`<span class="text-sm font-medium text-white truncate block"
>${item.title}</span
>`}
<span class="text-xs text-white/50 truncate block"
>${item.description}</span
>
</div>
${this.items.length > 1
? html`
<div class="flex gap-1 shrink-0">
${this.items.map(
(_, i) => html`
<button
@click=${() => this.goTo(i)}
class="w-1.5 h-1.5 rounded-full transition-colors ${i ===
this.activeIndex
? "bg-white/60"
: "bg-white/20 hover:bg-white/40"}"
aria-label="${translateText("news_box.go_to_item", {
num: i + 1,
})}"
></button>
`,
)}
</div>
`
: nothing}
<button
@click=${() => this.dismiss(item.id)}
class="shrink-0 p-0.5 text-white/30 hover:text-white/70 transition-colors"
aria-label="${translateText("news_box.dismiss")}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-3.5 h-3.5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button>
</div>
</div>
`;
}
}
6 changes: 5 additions & 1 deletion src/client/components/PlayPage.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
import "./NewsBox";

@customElement("play-page")
export class PlayPage extends LitElement {
Expand All @@ -11,7 +12,7 @@ export class PlayPage extends LitElement {
return html`
<div
id="page-play"
class="flex flex-col gap-2 w-full px-0 lg:px-4 lg:my-auto min-h-0"
class="flex flex-col gap-2 w-full px-0 lg:px-4 min-h-0"
>
<token-login class="absolute"></token-login>

Expand Down Expand Up @@ -107,6 +108,9 @@ export class PlayPage extends LitElement {
class="lg:hidden h-[calc(env(safe-area-inset-top)+56px)] lg:col-span-2 -mb-4"
></div>

<!-- News box above username -->
<news-box class="lg:col-span-2"></news-box>

<!-- Username: left col -->
<div
class="px-2 py-2 bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)] border-y border-white/10 overflow-visible lg:flex lg:items-center lg:gap-x-2 lg:h-[60px] lg:p-3 lg:relative lg:z-20 lg:border-y-0 lg:rounded-xl"
Expand Down
9 changes: 9 additions & 0 deletions src/core/ApiSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,3 +191,12 @@ export const RankedLeaderboardResponseSchema = z.object({
export type RankedLeaderboardResponse = z.infer<
typeof RankedLeaderboardResponseSchema
>;

export const NewsItemSchema = z.object({
id: z.string(),
title: z.string(),
description: z.string(),
url: z.string().nullable().optional(),
type: z.enum(["tournament", "tutorial", "announcement"]).or(z.string()),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Verify how NewsItem.type is consumed and whether exhaustive handling is expected.
rg -n -C2 --type ts --type tsx 'NewsItem\b|NewsItemSchema\b|\.type\b|switch\s*\(.*type' 

# Look for branches that assume only known literals.
rg -n -C2 --type ts --type tsx '===\s*["'"'"'](tournament|tutorial|announcement)["'"'"']|case\s+["'"'"'](tournament|tutorial|announcement)["'"'"']'

Repository: openfrontio/OpenFrontIO

Length of output: 94


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for NewsItem and NewsItemSchema usage with corrected syntax
rg -n -C2 'NewsItem\b|NewsItemSchema\b' --type ts

# Look for specific type value checks (tournament, tutorial, announcement)
echo "=== Type value checks ==="
rg -n -C2 '(tournament|tutorial|announcement)' --type ts | head -50

Repository: openfrontio/OpenFrontIO

Length of output: 7859


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check the exact definition of NewsItem["type"] and how Record<> uses it
cat -n src/client/components/NewsBox.ts | sed -n '35,45p'

# Look for any type errors in the NewsBox file
cat -n src/core/ApiSchemas.ts | sed -n '195,202p'

# Check if TypeScript would allow this pattern with z.string().or()
echo "=== Checking how Zod infers the type ==="
grep -A5 'export type NewsItem' src/core/ApiSchemas.ts

Repository: openfrontio/OpenFrontIO

Length of output: 949


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check if there are runtime safety checks when accessing the type
cat -n src/client/components/NewsBox.ts | sed -n '50,100p'

# Check for any optional chaining or safety checks on typeLabelKeys/typeLabelColors access
rg -n 'typeLabelKeys\[|typeLabelColors\[|typeLabelKeys\.get|typeLabelColors\.get' src/client/components/NewsBox.ts -A2

Repository: openfrontio/OpenFrontIO

Length of output: 1948


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check if TypeScript would allow Record<string, T> to be assigned 3 properties
# by examining the actual behavior. Let's also check the news.json fallback to see
# what types are actually used in practice

cat -n resources/news.json | head -30

# Check if there are any error handling patterns for unknown types
rg -n 'typeLabelKeys|typeLabelColors' src/client/components/NewsBox.ts -B1 -A3

Repository: openfrontio/OpenFrontIO

Length of output: 1654


Fix the type field to enforce known values only or use a type guard.

The current schema z.enum(...).or(z.string()) collapses the type to plain string. This creates a gap: the consumer code in NewsBox.ts uses Record<NewsItem["type"], string> with exactly three keys (tournament, tutorial, announcement) and accesses them directly without safety checks. If the API returns an unknown type string, the code will render undefined in CSS classes and translation keys.

Since all actual data only uses the three known types, split the schema into a strict enum for validation and add a type guard for safe narrowing:

♻️ Suggested refactor
+export const KnownNewsTypeSchema = z.enum(["tournament", "tutorial", "announcement"]);
+export type KnownNewsType = z.infer<typeof KnownNewsTypeSchema>;
+
 export const NewsItemSchema = z.object({
   id: z.string(),
   title: z.string(),
   description: z.string(),
   url: z.string().nullable().optional(),
-  type: z.enum(["tournament", "tutorial", "announcement"]).or(z.string()),
+  type: KnownNewsTypeSchema,
 });
 export type NewsItem = z.infer<typeof NewsItemSchema>;
+
+export const isKnownNewsType = (value: string): value is KnownNewsType =>
+  KnownNewsTypeSchema.safeParse(value).success;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
type: z.enum(["tournament", "tutorial", "announcement"]).or(z.string()),
export const KnownNewsTypeSchema = z.enum(["tournament", "tutorial", "announcement"]);
export type KnownNewsType = z.infer<typeof KnownNewsTypeSchema>;
export const NewsItemSchema = z.object({
id: z.string(),
title: z.string(),
description: z.string(),
url: z.string().nullable().optional(),
type: KnownNewsTypeSchema,
});
export type NewsItem = z.infer<typeof NewsItemSchema>;
export const isKnownNewsType = (value: string): value is KnownNewsType =>
KnownNewsTypeSchema.safeParse(value).success;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/ApiSchemas.ts` at line 200, The schema's `type` currently uses
`z.enum(["tournament","tutorial","announcement"]).or(z.string())` which degrades
to plain string; change it to a strict enum (remove `.or(z.string())`) so
`NewsItem.type` is one of the three known values, and add a runtime type guard
like `isKnownNewsType(value: string): value is NewsItem["type"]` used in
`NewsBox.ts` to narrow values before indexing `Record<NewsItem["type"], string>`
(validate API input with the strict enum and guard unknown strings before
accessing `Record` to avoid undefined class/translation lookups).

});
export type NewsItem = z.infer<typeof NewsItemSchema>;
88 changes: 88 additions & 0 deletions tests/client/components/NewsBox.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import newsItems from "../../../resources/news.json";
import {
getVisibleNewsItems,
NewsItem,
} from "../../../src/client/components/NewsBox";

const DISMISSED_NEWS_KEY = "dismissedNewsItems";
const allItems = newsItems as NewsItem[];

function createMockLocalStorage(): Storage {
let store: Record<string, string> = {};
return {
getItem: (key: string) => store[key] ?? null,
setItem: (key: string, value: string) => {
store[key] = String(value);
},
removeItem: (key: string) => {
delete store[key];
},
clear: () => {
store = {};
},
get length() {
return Object.keys(store).length;
},
key: (index: number) => Object.keys(store)[index] ?? null,
};
}

describe("NewsBox", () => {
beforeEach(() => {
vi.stubGlobal("localStorage", createMockLocalStorage());
});

afterEach(() => {
vi.unstubAllGlobals();
});

describe("getVisibleNewsItems", () => {
it("returns all items when none are dismissed", () => {
const items = getVisibleNewsItems(allItems);
expect(items.length).toBe(newsItems.length);
});

it("filters out dismissed items", () => {
const items = getVisibleNewsItems(allItems);
const firstId = items[0].id;
localStorage.setItem(DISMISSED_NEWS_KEY, JSON.stringify([firstId]));
const filtered = getVisibleNewsItems(allItems);
expect(filtered.find((i) => i.id === firstId)).toBeUndefined();
expect(filtered.length).toBe(items.length - 1);
});

it("returns empty when all items are dismissed", () => {
const allIds = allItems.map((i) => i.id);
localStorage.setItem(DISMISSED_NEWS_KEY, JSON.stringify(allIds));
const items = getVisibleNewsItems(allItems);
expect(items.length).toBe(0);
});
});

describe("news items structure", () => {
it("each item has required fields", () => {
const items = getVisibleNewsItems(allItems);
for (const item of items) {
expect(item.id).toBeDefined();
expect(typeof item.id).toBe("string");
expect(item.title).toBeDefined();
expect(typeof item.title).toBe("string");
expect(item.description).toBeDefined();
expect(typeof item.description).toBe("string");
expect(item.type).toBeDefined();
expect(["tournament", "tutorial", "announcement"]).toContain(item.type);
}
});

it("each item has a unique id", () => {
const items = getVisibleNewsItems(allItems);
const ids = items.map((i) => i.id);
expect(new Set(ids).size).toBe(ids.length);
});

it("contains a tournament entry", () => {
const items = getVisibleNewsItems(allItems);
expect(items.some((i) => i.type === "tournament")).toBe(true);
});
});
});
Loading