Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion core/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,9 +154,13 @@ export function renderIndexPage(data: PageData): string {
<link rel="stylesheet" href="/static/style.css?v=45" />
<script src="/static/app.js?v=36" defer></script>
</head>
<body>
<body data-app-version="${escapeHtml(data.appVersion)}">
<header class="shell page-header">
<div class="toast" data-toast role="status" aria-live="polite"></div>
<div class="update-banner is-hidden" data-update-banner role="status" aria-live="polite">
<span>A new version is available.</span>
<button class="update-banner-button" type="button" data-update-refresh>Refresh</button>
</div>
<div class="header-top">
<h1 class="brand"><img class="brand-mark" src="/favicon.svg?v=7" alt="" aria-hidden="true" /><span>reader</span></h1>
<div class="header-actions">
Expand Down
19 changes: 18 additions & 1 deletion platforms/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export default {
if (url.pathname === "/") return handleHome(url, env, repo);
if (url.pathname === "/healthz") return handleHealthz(sources, repo);
if (url.pathname === "/api/items") return handleItemsApi(url, repo);
if (url.pathname === "/api/version") return handleVersion(env);
if (url.pathname === "/api/refresh") {
if (request.method !== "POST") {
return new Response("method not allowed", {
Expand Down Expand Up @@ -69,7 +70,7 @@ async function handleHome(
const querySource = source === "all" ? "" : source;
const canonicalUrl = `${url.origin}${url.pathname}${url.search}`;
const socialImageUrl = `${url.origin}/og-image.png?v=2`;
const appVersion = env.APP_VERSION?.trim() || "dev";
const appVersion = currentAppVersion(env);

const { items, hasNext } = await feedItems(
repo,
Expand Down Expand Up @@ -109,6 +110,22 @@ async function handleHealthz(
return Response.json(payload);
}

/** Lets the client (notably an installed PWA resuming from the background,
* which never re-runs index.html's inline bootstrap) detect that a new
* version has been deployed and prompt for a refresh — see
* web-static/static/app.js's checkForUpdate(). Deliberately uncached: it
* must always reflect the currently-deployed APP_VERSION. */
function handleVersion(env: Env): Response {
return Response.json(
{ version: currentAppVersion(env) },
{ headers: { "Cache-Control": "no-store" } },
);
}

function currentAppVersion(env: Env): string {
return env.APP_VERSION?.trim() || "dev";
}

async function handleItemsApi(url: URL, repo: D1Repository): Promise<Response> {
const source = normalizeSource(url.searchParams.get("source") ?? "");
const searchQuery = normalizeSearchQuery(url.searchParams.get("q") ?? "");
Expand Down
50 changes: 49 additions & 1 deletion web-static/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@
"[data-install-screenshot-desktop]",
);
const toast = document.querySelector("[data-toast]");
const updateBanner = document.querySelector("[data-update-banner]");
const updateRefreshButton = document.querySelector("[data-update-refresh]");
const pageSize = Number(cardsGrid?.dataset.pageSize || 12);
const searchDebounceMs = 1100;
const sourceConfigStorageKey = "feedreader.sources";
Expand All @@ -75,6 +77,8 @@
const visitedLinksLimit = 500;
const themeStorageKey = "feedreader.theme";
const metaThemeColor = document.querySelector('meta[name="theme-color"]');
const loadedAppVersion = document.body.dataset.appVersion || "";
const updateCheckIntervalMs = 15 * 60 * 1000;

let activeFilter = cardsGrid?.dataset.currentSource || "all";
let selectedSources = loadSelectedSources();
Expand Down Expand Up @@ -390,6 +394,29 @@
renderConnectionIndicator();
};

const showUpdateBanner = () => {
updateBanner?.classList.remove("is-hidden");
};

let checkingForUpdate = false;
const checkForUpdate = async () => {
if (!loadedAppVersion || checkingForUpdate) return;
checkingForUpdate = true;
try {
const response = await fetch("/api/version", { cache: "no-store" });
if (!response.ok) return;
const payload = await response.json();
if (payload.version && payload.version !== loadedAppVersion) {
showUpdateBanner();
}
} catch {
// Offline or request failed — try again on the next visibility/focus
// event rather than erroring the page.
} finally {
checkingForUpdate = false;
}
};

const renderViewMore = () => {
const showActions = loadedCount > 0;
if (footerActions) {
Expand Down Expand Up @@ -1177,10 +1204,31 @@

if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/service-worker.js").catch(() => {});
navigator.serviceWorker
.register("/service-worker.js")
.then((registration) => registration.update().catch(() => {}))
.catch(() => {});
});
}

updateRefreshButton?.addEventListener("click", () => {
window.location.reload();
});

// An installed PWA reopened from the home screen on iOS/iPadOS resumes a
// frozen page rather than re-running this script, so neither a periodic
// timer nor "load" alone can see a deploy that happened while it was
// backgrounded. visibilitychange/pageshow catch the resume itself; the
// interval is a fallback for long foreground sessions that never trigger
// either.
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") checkForUpdate();
});
window.addEventListener("pageshow", () => checkForUpdate());
window.setInterval(() => {
if (document.visibilityState === "visible") checkForUpdate();
}, updateCheckIntervalMs);

const savedTheme = localStorage.getItem(themeStorageKey);
applyTheme(savedTheme === "light" ? "light" : "dark");
applyUIDensity(uiDensity, { persist: false });
Expand Down
30 changes: 30 additions & 0 deletions web-static/static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -701,6 +701,36 @@ body.is-dialog-open {
display: none;
}

.update-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
padding: 0.55rem 0.7rem 0.55rem 0.9rem;
border-radius: 0.9rem;
border: 1px solid color-mix(in srgb, var(--accent) 36%, var(--border));
background: var(--accent-soft);
color: var(--text);
font-size: 0.88rem;
}

.update-banner-button {
flex: 0 0 auto;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
cursor: pointer;
border-radius: 999px;
padding: 0.4rem 0.9rem;
font-size: 0.85rem;
font-weight: 600;
transition: 140ms ease;
}

.update-banner-button:hover {
border-color: color-mix(in srgb, var(--accent) 44%, var(--border));
}

.page-body {
padding-bottom: calc(0.35rem + env(safe-area-inset-bottom));
}
Expand Down