feat(news): What's New modal - product news feed, once-per-user queue

Editor shows scheduled product-news posts as a markdown modal: max one
per day, oldest first, each post only ever once (marked seen at
delivery). OSS default source proxies velxio.dev's public feed through
the local backend (cached ~6h, anonymous, VELXIO_NEWS=off to disable)
with seen-state in localStorage; a pro overlay can register a
server-backed per-user source via lib/newsSource.ts.
This commit is contained in:
David Montero Crespo 2026-08-05 15:38:14 +02:00
parent c9fe6f199d
commit 801bb94eab
7 changed files with 453 additions and 0 deletions

View File

@ -345,6 +345,7 @@ change the CORS origin used during local development.
| Variable | Default | Description |
| --- | --- | --- |
| `FRONTEND_URL` | `http://localhost:5173` | Origin allowed by CORS for local Vite dev |
| `VELXIO_NEWS` | `on` | Product news: the backend fetches release notes and product news from velxio.dev's public feed (cached ~6h, anonymous, no identifiers sent) and the editor shows each item once as a "What's New" modal. Set to `off` to disable entirely; an offline host degrades to no news automatically. |
> **Deploying behind a reverse proxy?** The container listens on plain HTTP
> on port 80 and accepts any `Host` header — no `server_name` whitelist.

View File

@ -0,0 +1,91 @@
"""Product-news feed proxy for self-hosted installs.
The editor shows a once-per-browser "What's New" modal (see
frontend/src/lib/newsSource.ts). Rather than every user's browser
fetching velxio.dev directly, the backend proxies the public feed:
- users' browsers only ever talk to this server;
- one cached fetch per instance instead of one per visitor;
- a single, documented opt-out: VELXIO_NEWS=off (also the automatic
behavior when the host has no internet — errors degrade to []).
The upstream feed is plain public JSON (no auth, no identifiers sent).
"""
from __future__ import annotations
import logging
import os
import time
import httpx
from fastapi import APIRouter
logger = logging.getLogger(__name__)
router = APIRouter()
DEFAULT_FEED_URL = "https://velxio.dev/api/pro/news/feed"
_CACHE_TTL_SECONDS = 6 * 60 * 60
_cache: dict = {"at": 0.0, "posts": None}
def _news_enabled() -> bool:
return os.environ.get("VELXIO_NEWS", "on").strip().lower() not in (
"off", "0", "false", "disabled", "no",
)
def _sanitize(raw: object) -> list[dict]:
"""Keep only the expected shape/fields — the payload gets rendered
client-side, so an upstream surprise must not leak arbitrary junk."""
if not isinstance(raw, list):
return []
posts: list[dict] = []
for item in raw[:50]:
if not isinstance(item, dict):
continue
try:
posts.append({
"id": str(item["id"])[:64],
"title": str(item["title"])[:200],
"body_md": str(item["body_md"])[:20_000],
"publish_date": str(item["publish_date"])[:10],
"expires_date": (
str(item["expires_date"])[:10]
if item.get("expires_date") else None
),
})
except (KeyError, TypeError):
continue
return posts
@router.get("/feed")
async def news_feed() -> list[dict]:
"""Cached proxy of the velxio.dev product-news feed.
Returns [] when news is disabled, the host is offline, or upstream
misbehaves — the modal simply never appears.
"""
if not _news_enabled():
return []
now = time.monotonic()
if _cache["posts"] is not None and now - _cache["at"] < _CACHE_TTL_SECONDS:
return _cache["posts"]
url = os.environ.get("VELXIO_NEWS_URL", DEFAULT_FEED_URL)
try:
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.get(url)
resp.raise_for_status()
posts = _sanitize(resp.json())
except Exception as exc: # offline / DNS / upstream down — all fine
logger.debug("[news] feed fetch failed (%s); serving stale/empty", exc)
return _cache["posts"] or []
_cache["posts"] = posts
_cache["at"] = now
return posts

View File

@ -115,6 +115,13 @@ app.include_router(simulation.router, prefix="/api/simulation", tags=["simulatio
from app.api.routes import iot_gateway
app.include_router(iot_gateway.router, prefix="/api/gateway", tags=["iot-gateway"])
# Product news ("What's New" modal) — cached proxy of velxio.dev's public
# feed so self-hosted users get product news without their browsers ever
# leaving this server. Anonymous, no identifiers sent; opt out with
# VELXIO_NEWS=off (an offline host just serves an empty feed).
from app.api.routes import news
app.include_router(news.router, prefix="/api/news", tags=["news"])
# Optional pro extension. The `app.pro` package only exists in private builds
# (overlaid at Docker build time by an external repo) — its absence in the
# open-source image is expected and silently ignored. Anyone with private

View File

@ -0,0 +1,180 @@
.velxio-news-overlay {
position: fixed;
inset: 0;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.55);
animation: velxio-news-fade 0.2s ease-out;
}
@keyframes velxio-news-fade {
from { opacity: 0; }
to { opacity: 1; }
}
.velxio-news-modal {
background: #1e2228;
border: 1px solid #3c4049;
border-radius: 12px;
width: min(560px, calc(100vw - 32px));
max-height: min(80vh, 640px);
display: flex;
flex-direction: column;
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.6);
animation: velxio-news-in 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes velxio-news-in {
from { opacity: 0; transform: translateY(16px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.velxio-news-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 18px 20px 12px;
border-bottom: 1px solid #2b3038;
}
.velxio-news-kicker {
display: block;
color: #4fc3f7;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 4px;
}
.velxio-news-title {
color: #e1e4e8;
font-size: 18px;
font-weight: 600;
margin: 0;
line-height: 1.3;
}
.velxio-news-close {
background: none;
border: none;
color: #8b949e;
font-size: 22px;
line-height: 1;
cursor: pointer;
padding: 2px 6px;
border-radius: 6px;
flex-shrink: 0;
}
.velxio-news-close:hover {
color: #e1e4e8;
background: #2b3038;
}
.velxio-news-body {
padding: 14px 20px;
overflow-y: auto;
color: #c9d1d9;
font-size: 13.5px;
line-height: 1.55;
}
.velxio-news-body h1,
.velxio-news-body h2,
.velxio-news-body h3 {
color: #e1e4e8;
margin: 14px 0 6px;
line-height: 1.3;
}
.velxio-news-body h1 { font-size: 17px; }
.velxio-news-body h2 { font-size: 15px; }
.velxio-news-body h3 { font-size: 14px; }
.velxio-news-body p {
margin: 0 0 10px;
}
.velxio-news-body ul,
.velxio-news-body ol {
margin: 0 0 10px;
padding-left: 22px;
}
.velxio-news-body li {
margin-bottom: 4px;
}
.velxio-news-body a {
color: #4fc3f7;
text-decoration: none;
}
.velxio-news-body a:hover {
text-decoration: underline;
}
.velxio-news-body code {
background: #2b3038;
border-radius: 4px;
padding: 1px 5px;
font-size: 12.5px;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
.velxio-news-body pre {
background: #14171c;
border: 1px solid #2b3038;
border-radius: 8px;
padding: 10px 12px;
overflow-x: auto;
margin: 0 0 10px;
}
.velxio-news-body pre code {
background: none;
padding: 0;
}
.velxio-news-body img {
max-width: 100%;
border-radius: 8px;
}
.velxio-news-body table {
border-collapse: collapse;
margin: 0 0 10px;
}
.velxio-news-body th,
.velxio-news-body td {
border: 1px solid #3c4049;
padding: 5px 10px;
font-size: 12.5px;
}
.velxio-news-footer {
display: flex;
justify-content: flex-end;
padding: 12px 20px 16px;
border-top: 1px solid #2b3038;
}
.velxio-news-ok {
background: #4fc3f7;
color: #10131a;
border: none;
border-radius: 8px;
font-size: 13px;
font-weight: 700;
padding: 8px 18px;
cursor: pointer;
}
.velxio-news-ok:hover {
filter: brightness(1.1);
}

View File

@ -0,0 +1,72 @@
/**
* "What's New" modal — shows the next pending product-news post when the
* user enters the editor. At most one per day, each post only ever once;
* the queue/seen logic lives in lib/newsSource.ts (OSS: localStorage over
* the local /api/news/feed proxy; pro overlay: server-backed per-user).
*
* The fetch is delayed a few seconds so it never competes with editor
* startup, and every failure path resolves to "no modal".
*/
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { getNextNews, type NewsPost } from '../../lib/newsSource';
import './NewsModal.css';
const FETCH_DELAY_MS = 4000;
export function NewsModal() {
const [post, setPost] = useState<NewsPost | null>(null);
useEffect(() => {
let cancelled = false;
const timer = setTimeout(() => {
void getNextNews().then((p) => {
if (!cancelled && p) setPost(p);
});
}, FETCH_DELAY_MS);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, []);
if (!post) return null;
return createPortal(
<div className="velxio-news-overlay" onClick={() => setPost(null)}>
<div
className="velxio-news-modal"
role="dialog"
aria-modal="true"
aria-label={post.title}
onClick={(e) => e.stopPropagation()}
>
<div className="velxio-news-header">
<div>
<span className="velxio-news-kicker">What's new</span>
<h2 className="velxio-news-title">{post.title}</h2>
</div>
<button
className="velxio-news-close"
aria-label="Close"
onClick={() => setPost(null)}
>
×
</button>
</div>
<div className="velxio-news-body">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{post.body_md}</ReactMarkdown>
</div>
<div className="velxio-news-footer">
<button className="velxio-news-ok" onClick={() => setPost(null)}>
Got it
</button>
</div>
</div>
</div>,
document.body,
);
}

View File

@ -0,0 +1,100 @@
/**
* Product-news source registry ("What's New" modal).
*
* Same seam pattern as proSession.ts: OSS ships a default source and the
* pro overlay may replace it.
*
* - OSS default: fetch the local backend's /api/news/feed (a cached,
* opt-out-able proxy of velxio.dev's public feed — see
* backend/app/api/routes/news.py) and run the delivery queue in the
* browser, with seen-state in localStorage. An anonymous install has
* no accounts, so "once per user" means "once per browser" here.
* - Pro overlay: registers a server-backed source (GET /api/pro/news/next)
* where the queue and per-user seen tracking live in the database.
*
* Queue semantics (identical in both sources):
* - only posts with publish_date <= today and not expired;
* - at most one post per calendar day;
* - oldest publish_date first;
* - a post is marked seen when SERVED, never repeated.
*/
import { getApiBase } from './apiBase';
export interface NewsPost {
id: string;
title: string;
body_md: string;
publish_date: string; // YYYY-MM-DD
expires_date?: string | null;
}
type NewsSource = () => Promise<NewsPost | null>;
let _source: NewsSource | null = null;
export function registerNewsSource(source: NewsSource): void {
_source = source;
}
const SEEN_KEY = 'velxio_news_seen';
const LAST_SHOWN_KEY = 'velxio_news_last_shown';
function localToday(): string {
const d = new Date();
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${d.getFullYear()}-${mm}-${dd}`;
}
function readSeen(): Set<string> {
try {
const raw = JSON.parse(localStorage.getItem(SEEN_KEY) ?? '[]');
return new Set(Array.isArray(raw) ? raw.filter((x) => typeof x === 'string') : []);
} catch {
return new Set();
}
}
async function ossFeedNext(): Promise<NewsPost | null> {
const today = localToday();
// Max one post per day — bail before even fetching.
if (localStorage.getItem(LAST_SHOWN_KEY) === today) return null;
const resp = await fetch(`${getApiBase()}/news/feed`);
if (!resp.ok) return null;
const posts: NewsPost[] = await resp.json();
if (!Array.isArray(posts)) return null;
const seen = readSeen();
const pending = posts
.filter(
(p) =>
p && typeof p.id === 'string' && !seen.has(p.id) &&
p.publish_date <= today &&
(!p.expires_date || p.expires_date >= today),
)
.sort((a, b) => a.publish_date.localeCompare(b.publish_date));
const post = pending[0];
if (!post) return null;
// Mark seen at delivery time so a refresh can't show it twice.
seen.add(post.id);
localStorage.setItem(SEEN_KEY, JSON.stringify([...seen]));
localStorage.setItem(LAST_SHOWN_KEY, today);
return post;
}
/**
* Resolve the next news post to show, or null. Never throws — news is
* strictly best-effort and must not disturb the editor.
*/
export async function getNextNews(): Promise<NewsPost | null> {
try {
return _source ? await _source() : await ossFeedNext();
} catch (err) {
// eslint-disable-next-line no-console
console.debug('[oss] news source failed:', err);
return null;
}
}

View File

@ -20,6 +20,7 @@ import { Oscilloscope } from '../components/simulator/Oscilloscope';
import { AppHeader } from '../components/layout/AppHeader';
import { triggerSaveAction } from '../lib/proSaveAction';
import { GitHubStarBanner } from '../components/layout/GitHubStarBanner';
import { NewsModal } from '../components/layout/NewsModal';
import { useSimulatorStore, DEFAULT_BOARD_POSITION } from '../store/useSimulatorStore';
import { useEditorStore } from '../store/useEditorStore';
import { useCompileLogsStore } from '../store/useCompileLogsStore';
@ -748,6 +749,7 @@ export const EditorPage: React.FC = () => {
round={starRound}
/>
)}
<NewsModal />
{/* Slot reserved for the private pro overlay (e.g. agent chat panel).
Self-hosted builds without an overlay see nothing here. */}
<div data-velxio-slot="agent-chat" />