From aebc0ac97eddc75dfbb666a22efd6407070f810b Mon Sep 17 00:00:00 2001 From: Tautvydas Derzinskas Date: Wed, 21 Oct 2026 21:15:29 +0200 Subject: [PATCH] feat: show what's new after an update The first time the backend starts on a newer version than it last ran, every user gets a system notification that opens a What's new dialog; a fresh install isn't told. The dialog is also in the user menu. Its content is fetched from main, so it can be written after a release: release-notes/latest-release-notes.json when its version matches the instance's, otherwise the backend's CHANGELOG.md entry with the frontend release that went out with it. An instance that skipped versions gets every release since the one it ran, up to five. Curated items can be highlighted, carry screenshots and a command to copy. RELEASE_NOTES_URL points at another copy, or turns fetching off when empty. --- backend/src/app.ts | 2 + backend/src/config.ts | 7 + backend/src/routes/releaseNotes.ts | 35 ++ backend/src/server.ts | 2 + backend/src/services/releaseNotesService.ts | 310 ++++++++++ .../src/services/systemNotificationService.ts | 2 +- backend/src/services/versionService.ts | 2 +- backend/tests/releaseNotes.test.ts | 184 ++++++ docs/DEVELOPMENT.md | 44 ++ frontend/src/api/releaseNotes.ts | 41 ++ frontend/src/components/Layout/AppLayout.tsx | 5 +- frontend/src/components/Layout/UserMenu.tsx | 14 + .../components/whatsNew/WhatsNewDialog.tsx | 577 ++++++++++++++++++ .../components/whatsNew/WhatsNewProvider.tsx | 41 ++ frontend/src/i18n/locales/en.json | 22 + frontend/src/i18n/locales/es.json | 22 + frontend/src/i18n/locales/fr.json | 22 + frontend/src/i18n/locales/lt.json | 22 + 18 files changed, 1351 insertions(+), 3 deletions(-) create mode 100644 backend/src/routes/releaseNotes.ts create mode 100644 backend/src/services/releaseNotesService.ts create mode 100644 backend/tests/releaseNotes.test.ts create mode 100644 frontend/src/api/releaseNotes.ts create mode 100644 frontend/src/components/whatsNew/WhatsNewDialog.tsx create mode 100644 frontend/src/components/whatsNew/WhatsNewProvider.tsx diff --git a/backend/src/app.ts b/backend/src/app.ts index 356bbb0..4547017 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -25,6 +25,7 @@ import providerCollectionsRoutes from "./routes/providerCollections"; import bookmarksRoutes from "./routes/bookmarks"; import searchRoutes from "./routes/search"; import notificationsRoutes from "./routes/notifications"; +import releaseNotesRoutes from "./routes/releaseNotes"; import adminRoutes from "./routes/admin"; import dashboardRoutes from "./routes/dashboard"; import activityRoutes from "./routes/activity"; @@ -71,6 +72,7 @@ export function createApp(): Express { app.use("/api", bookmarksRoutes); app.use("/api", searchRoutes); app.use("/api", notificationsRoutes); + app.use("/api", releaseNotesRoutes); // Own prefix: on bare /api, its requireAdmin would also run for every route mounted after it. app.use("/api/admin", adminRoutes); app.use("/api", dashboardRoutes); diff --git a/backend/src/config.ts b/backend/src/config.ts index 0aef003..d81170d 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -144,4 +144,11 @@ export const SYSTEM_NOTIFICATIONS_URL = ( process.env.SYSTEM_NOTIFICATIONS_URL ?? DEFAULT_SYSTEM_NOTIFICATIONS_URL ).trim(); +export const DEFAULT_RELEASE_NOTES_URL = "https://raw.githubusercontent.com/TautvydasDerzinskas/Thingport/main"; +// The repo files "What's new" reads: the CHANGELOG.md files and release-notes/latest-release-notes.json. Set to an +// empty value to stop fetching them. +export const RELEASE_NOTES_URL = (process.env.RELEASE_NOTES_URL ?? DEFAULT_RELEASE_NOTES_URL) + .trim() + .replace(/\/+$/, ""); + export const API_PORT = envInt("API_PORT", 8000); diff --git a/backend/src/routes/releaseNotes.ts b/backend/src/routes/releaseNotes.ts new file mode 100644 index 0000000..4f81d7a --- /dev/null +++ b/backend/src/routes/releaseNotes.ts @@ -0,0 +1,35 @@ +import { Router } from "express"; +import { z } from "zod"; +import { requireAuth } from "../auth"; +import { asyncHandler } from "../utils/asyncHandler"; +import { HttpError } from "../utils/fileUtils"; +import { parseBody } from "../utils/validate"; +import { getReleaseNotes } from "../services/releaseNotesService"; + +const router = Router(); + +const semver = z + .string() + .trim() + .regex(/^\d+\.\d+\.\d+$/); +const querySchema = z.object({ + // This backend's version when omitted. + version: semver.optional(), + // The version updated from: the notes then cover every release since. + since: semver.optional(), + // The frontend is versioned apart from the backend, and only it knows its own. + frontend: semver.optional(), +}); + +router.get( + "/release-notes", + requireAuth, + asyncHandler(async (req, res) => { + const { version, since, frontend } = parseBody(querySchema, req.query); + const notes = await getReleaseNotes({ version, since, frontendVersion: frontend }); + if (!notes) throw new HttpError(404, "This instance's version is unknown"); + res.json(notes); + }), +); + +export default router; diff --git a/backend/src/server.ts b/backend/src/server.ts index 16c06fc..d8ac6b7 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -7,6 +7,7 @@ import { SYNC_JOB_INTERRUPTED } from "./services/importJobRunner"; import { seedFlaresolverrUrl, seedPublicUrl } from "./services/settingsService"; import { startNotificationCleanup } from "./services/notificationService"; import { startSystemNotificationSync } from "./services/systemNotificationService"; +import { announceVersionUpdate } from "./services/releaseNotesService"; import { resumeStorageMove } from "./services/storageMoveService"; const app = createApp(); @@ -33,5 +34,6 @@ app.listen(API_PORT, () => { startCollectionSyncScheduler(); startNotificationCleanup(); startSystemNotificationSync(); + announceVersionUpdate().catch((err) => console.error("Failed to announce the version update:", err)); resumeStorageMove().catch((err) => console.error("Failed to resume moving model files:", err)); }); diff --git a/backend/src/services/releaseNotesService.ts b/backend/src/services/releaseNotesService.ts new file mode 100644 index 0000000..4f37950 --- /dev/null +++ b/backend/src/services/releaseNotesService.ts @@ -0,0 +1,310 @@ +import { z } from "zod"; +import { prisma } from "../db"; +import { RELEASE_NOTES_URL } from "../config"; +import { compareVersions } from "./systemNotificationService"; +import { GITHUB_REPO, getBackendVersion } from "./versionService"; + +const FETCH_TIMEOUT_MS = 15_000; +const CACHE_TTL_MS = 30 * 60 * 1000; +// Someone skipping many releases gets the most recent ones, not a wall of every fix since. +const MAX_VERSIONS = 5; +const LAST_RUN_VERSION_KEY = "last_run_version"; +const SEMVER = /^\d+\.\d+\.\d+$/; + +export const CURATED_NOTES_PATH = "release-notes/latest-release-notes.json"; + +export type ReleaseNoteKind = "breaking" | "feature" | "improvement" | "fix"; +const KIND_ORDER: ReleaseNoteKind[] = ["breaking", "feature", "improvement", "fix"]; + +export type ReleaseNoteItem = { + kind: ReleaseNoteKind; + title: string; + description: string | null; + /** The conventional-commit scope, e.g. "i18n". */ + scope: string | null; + images: { url: string; caption: string | null }[]; + /** A command to copy, e.g. how to connect an AI assistant. */ + code: string | null; + /** Shown as a large card above the rest. */ + highlight: boolean; + url: string | null; +}; + +export type ReleaseNotes = { + version: string; + /** "curated" from the hand-written file, "changelog" from CHANGELOG.md, "none" when neither could be read. */ + source: "curated" | "changelog" | "none"; + title: string | null; + summary: string | null; + items: ReleaseNoteItem[]; + releases_url: string; +}; + +/** Where a notification opens the What's new dialog; a bare query keeps whichever page is open. */ +export function whatsNewPath(version: string, since: string | null): string { + const params = new URLSearchParams({ "whats-new": version }); + if (since) params.set("since", since); + return `?${params}`; +} + +/** At startup: when this version is newer than the one that ran last, every user gets a notification that opens + * What's new. A fresh install has no users yet, so only an instance that ran before this check is told. */ +export async function announceVersionUpdate(version = getBackendVersion(), now = new Date()): Promise { + if (!version || !SEMVER.test(version)) return false; + const row = await prisma.setting.findUnique({ where: { key: LAST_RUN_VERSION_KEY } }); + const previous = typeof row?.value === "string" && SEMVER.test(row.value) ? row.value : null; + if (previous === version) return false; + const updated = previous ? compareVersions(version, previous) > 0 : (await prisma.user.count()) > 0; + const users = updated ? await prisma.user.findMany({ select: { id: true } }) : []; + await prisma.$transaction([ + prisma.setting.upsert({ + where: { key: LAST_RUN_VERSION_KEY }, + create: { key: LAST_RUN_VERSION_KEY, value: version }, + update: { value: version }, + }), + prisma.notification.createMany({ + data: users.map((user) => ({ + userId: user.id, + type: "system", + title: `Thanks for updating to Thingport ${version}`, + body: "This release brings a lot of new things. Click here to see what's new.", + internalPath: whatsNewPath(version, previous), + createdAt: now, + })), + }), + ]); + return updated; +} + +const curatedSchema = z.object({ + version: z.string().trim().regex(SEMVER), + title: z.string().trim().max(200).nullish(), + summary: z.string().trim().max(2000).nullish(), + items: z + .array( + z.object({ + type: z.enum(["breaking", "feature", "improvement", "fix"]), + title: z.string().trim().min(1).max(200), + description: z.string().trim().max(2000).nullish(), + highlight: z.boolean().default(false), + code: z.string().trim().max(1000).nullish(), + // Relative to the JSON file, e.g. "screenshots/1.2.0-dashboard.png", or a full https URL. + images: z + .array(z.object({ src: z.string().trim().min(1).max(500), caption: z.string().trim().max(200).nullish() })) + .max(6) + .default([]), + url: z + .string() + .url() + .refine((value) => value.startsWith("https://")) + .nullish(), + }), + ) + .min(1) + .max(100), +}); + +/** Null when the file is malformed: the changelog is shown instead. */ +export function parseCuratedNotes(data: unknown, fileUrl: string): Omit | null { + const parsed = curatedSchema.safeParse(data); + if (!parsed.success) return null; + const allowed = new Set(["https:", new URL(fileUrl).protocol]); + const resolve = (src: string): string | null => { + try { + const url = new URL(src, fileUrl); + return allowed.has(url.protocol) ? url.toString() : null; + } catch { + return null; + } + }; + const { version, title, summary, items } = parsed.data; + return { + version, + source: "curated", + title: title || null, + summary: summary || null, + items: sortItems( + items.map((item) => ({ + kind: item.type, + title: item.title, + description: item.description || null, + scope: null, + images: item.images.flatMap((image) => { + const url = resolve(image.src); + return url ? [{ url, caption: image.caption || null }] : []; + }), + code: item.code || null, + highlight: item.highlight, + url: item.url ?? null, + })), + ), + }; +} + +export type ChangelogRelease = { + version: string; + /** YYYY-MM-DD */ + date: string | null; + items: (ReleaseNoteItem & { sha: string | null })[]; +}; + +// "# [thingport-backend-v1.1.0](...compare...) (2026-10-20)" or "# thingport-backend-v1.0.0 (2026-10-17)"; +// semantic-release gives patch releases "##". +const RELEASE_HEADING = /^#{1,2} \[?[\w.-]*?v?(\d+\.\d+\.\d+)\]?(?:\([^)]*\))?(?: \((\d{4}-\d{2}-\d{2})\))?\s*$/; +const SECTION_HEADING = /^#{3,4} (.+?)\s*$/; +const SECTION_KINDS: Record = { + Features: "feature", + "Bug Fixes": "fix", + "Performance Improvements": "improvement", + "BREAKING CHANGES": "breaking", +}; +// "* **i18n:** count plural forms ([993c75c](https://.../commit/993c...)), closes [#91](...)" +const ITEM = /^[*-] (?:\*\*([^*]+?):\*\* )?(.+?)(?: \(\[([0-9a-f]{7,40})\]\((https:\/\/[^)\s]+)\)\))?(?:, closes .*)?$/; + +/** Commit subjects can carry markdown, e.g. "([#16](https://...))"; the dialog shows plain text. */ +function plainTitle(text: string): string { + const plain = text + .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/[*`]/g, "") + .trim(); + return plain.charAt(0).toUpperCase() + plain.slice(1); +} + +/** The releases in a semantic-release CHANGELOG.md, newest first as written. */ +export function parseChangelog(markdown: string): ChangelogRelease[] { + const releases: ChangelogRelease[] = []; + let release: ChangelogRelease | null = null; + let kind: ReleaseNoteKind | null = null; + for (const rawLine of markdown.split(/\r?\n/)) { + const line = rawLine.trimEnd(); + const heading = line.match(RELEASE_HEADING); + if (heading) { + release = { version: heading[1], date: heading[2] ?? null, items: [] }; + releases.push(release); + kind = null; + continue; + } + const section = line.match(SECTION_HEADING); + if (section) { + kind = SECTION_KINDS[section[1]] ?? null; + continue; + } + if (!release || !kind) continue; + const item = line.match(ITEM); + if (!item) continue; + release.items.push({ + kind, + title: plainTitle(item[2]), + description: null, + scope: item[1]?.trim() || null, + images: [], + code: null, + highlight: false, + url: item[4] ?? null, + sha: item[3] ?? null, + }); + } + return releases; +} + +function sortItems(items: T[]): T[] { + return items + .map((item, index) => ({ item, index })) + .toSorted((a, b) => KIND_ORDER.indexOf(a.item.kind) - KIND_ORDER.indexOf(b.item.kind) || a.index - b.index) + .map(({ item }) => item); +} + +/** The backend's releases after `since` up to `version` (just `version` without it), plus the frontend's releases + * out by then since the oldest of those: the two are versioned apart but released together. One change touching + * both shows once. */ +export function notesFromChangelogs( + backend: ChangelogRelease[], + frontend: ChangelogRelease[], + options: { version: string; since?: string | null; frontendVersion?: string | null }, +): ReleaseNoteItem[] { + const { version, since, frontendVersion } = options; + const inRange = (release: ChangelogRelease) => + compareVersions(release.version, version) <= 0 && + (since ? compareVersions(release.version, since) > 0 : release.version === version); + const backendReleases = backend + .filter(inRange) + .toSorted((a, b) => compareVersions(b.version, a.version)) + .slice(0, MAX_VERSIONS); + const dates = backendReleases.map((r) => r.date).filter((d): d is string => Boolean(d)); + const from = dates.length ? dates.reduce((a, b) => (a < b ? a : b)) : null; + const frontendReleases = from + ? frontend + .filter( + (r) => r.date && r.date >= from && (!frontendVersion || compareVersions(r.version, frontendVersion) <= 0), + ) + .slice(0, MAX_VERSIONS) + : []; + + const seen = new Set(); + const items: ReleaseNoteItem[] = []; + for (const release of [...backendReleases, ...frontendReleases]) { + for (const { sha, ...item } of release.items) { + const key = sha ?? `${item.kind}\n${item.title.toLowerCase()}`; + if (seen.has(key)) continue; + seen.add(key); + items.push(item); + } + } + return sortItems(items); +} + +const cache = new Map(); + +/** A failed fetch keeps the last good copy; misses are cached too, so a dialog opened offline doesn't wait each time. */ +async function fetchText(url: string, now = Date.now()): Promise { + const hit = cache.get(url); + if (hit && now - hit.at < CACHE_TTL_MS) return hit.text; + let text: string | null = null; + try { + const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); + if (res.ok) text = await res.text(); + } catch { + // offline or blocked + } + cache.set(url, { at: now, text: text ?? hit?.text ?? null }); + return text ?? hit?.text ?? null; +} + +export function clearReleaseNotesCache(): void { + cache.clear(); +} + +export async function getReleaseNotes( + options: { version?: string | null; since?: string | null; frontendVersion?: string | null } = {}, + baseUrl = RELEASE_NOTES_URL, +): Promise { + const version = options.version ?? getBackendVersion(); + if (!version || !SEMVER.test(version)) return null; + const releases_url = `https://github.com/${GITHUB_REPO}/releases`; + const empty: ReleaseNotes = { version, source: "none", title: null, summary: null, items: [], releases_url }; + if (!baseUrl) return empty; + + const curatedUrl = `${baseUrl}/${CURATED_NOTES_PATH}`; + const [curatedText, backendText, frontendText] = await Promise.all([ + fetchText(curatedUrl), + fetchText(`${baseUrl}/backend/CHANGELOG.md`), + fetchText(`${baseUrl}/frontend/CHANGELOG.md`), + ]); + + if (curatedText) { + try { + const curated = parseCuratedNotes(JSON.parse(curatedText), curatedUrl); + if (curated?.version === version) return { ...curated, releases_url }; + } catch { + // not JSON: fall through to the changelog + } + } + + if (!backendText) return empty; + const items = notesFromChangelogs(parseChangelog(backendText), parseChangelog(frontendText ?? ""), { + version, + since: options.since && SEMVER.test(options.since) ? options.since : null, + frontendVersion: options.frontendVersion && SEMVER.test(options.frontendVersion) ? options.frontendVersion : null, + }); + return { ...empty, source: "changelog", items }; +} diff --git a/backend/src/services/systemNotificationService.ts b/backend/src/services/systemNotificationService.ts index 5a8af34..b525488 100644 --- a/backend/src/services/systemNotificationService.ts +++ b/backend/src/services/systemNotificationService.ts @@ -39,7 +39,7 @@ const entrySchema = z.object({ }); export type SystemNotificationEntry = z.infer; -function compareVersions(a: string, b: string): number { +export function compareVersions(a: string, b: string): number { const pa = a.split(".").map(Number); const pb = b.split(".").map(Number); for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pa[i] - pb[i]; diff --git a/backend/src/services/versionService.ts b/backend/src/services/versionService.ts index 185d134..f439c54 100644 --- a/backend/src/services/versionService.ts +++ b/backend/src/services/versionService.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; -const GITHUB_REPO = "TautvydasDerzinskas/Thingport"; +export const GITHUB_REPO = "TautvydasDerzinskas/Thingport"; export type VersionCheckResult = { backend_version: string | null; diff --git a/backend/tests/releaseNotes.test.ts b/backend/tests/releaseNotes.test.ts new file mode 100644 index 0000000..301325c --- /dev/null +++ b/backend/tests/releaseNotes.test.ts @@ -0,0 +1,184 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import request from "supertest"; +import { createApp } from "../src/app"; +import { prisma } from "../src/db"; +import { + announceVersionUpdate, + notesFromChangelogs, + parseChangelog, + parseCuratedNotes, +} from "../src/services/releaseNotesService"; + +const BACKEND_CHANGELOG = `# Thingport backend changelog + +# [thingport-backend-v1.2.0](https://github.com/o/r/compare/a...b) (2026-11-02) + + +### Bug Fixes + +* **zip:** keep non-ASCII names readable ([060be2f](https://github.com/o/r/commit/060be2f1)), closes [#9](https://github.com/o/r/issues/9) +* shared fix ([aaaaaaa](https://github.com/o/r/commit/aaaaaaa1)) + + +### Features + +* add MCP endpoint, closing ([#16](https://github.com/o/r/issues/16)) ([bbbbbbb](https://github.com/o/r/commit/bbbbbbb1)) + +## [thingport-backend-v1.1.1](https://github.com/o/r/compare/a...b) (2026-10-25) + + +### Performance Improvements + +* load listings faster ([ccccccc](https://github.com/o/r/commit/ccccccc1)) + +# thingport-backend-v1.1.0 (2026-10-20) + + +### Features + +* old feature ([ddddddd](https://github.com/o/r/commit/ddddddd1)) +`; + +const FRONTEND_CHANGELOG = `# Thingport frontend changelog + +# [thingport-frontend-v1.3.0](https://github.com/o/r/compare/a...b) (2026-11-02) + + +### Bug Fixes + +* shared fix ([aaaaaaa](https://github.com/o/r/commit/aaaaaaa1)) + + +### Features + +* pick an accent colour ([eeeeeee](https://github.com/o/r/commit/eeeeeee1)) + +# [thingport-frontend-v1.2.0](https://github.com/o/r/compare/a...b) (2026-10-20) + + +### Features + +* old frontend feature ([fffffff](https://github.com/o/r/commit/fffffff1)) +`; + +describe("release notes", () => { + it("parses a semantic-release changelog", () => { + const releases = parseChangelog(BACKEND_CHANGELOG); + expect(releases.map((r) => [r.version, r.date])).toEqual([ + ["1.2.0", "2026-11-02"], + ["1.1.1", "2026-10-25"], + ["1.1.0", "2026-10-20"], + ]); + expect(releases[0].items[0]).toMatchObject({ + kind: "fix", + scope: "zip", + title: "Keep non-ASCII names readable", + url: "https://github.com/o/r/commit/060be2f1", + sha: "060be2f", + }); + expect(releases[1].items[0].kind).toBe("improvement"); + }); + + it("takes one release, features first, with the frontend's from the same day and shared commits once", () => { + const items = notesFromChangelogs(parseChangelog(BACKEND_CHANGELOG), parseChangelog(FRONTEND_CHANGELOG), { + version: "1.2.0", + frontendVersion: "1.3.0", + }); + expect(items.map((i) => [i.kind, i.title])).toEqual([ + ["feature", "Add MCP endpoint, closing (#16)"], + ["feature", "Pick an accent colour"], + ["fix", "Keep non-ASCII names readable"], + ["fix", "Shared fix"], + ]); + }); + + it("covers every release since the version updated from", () => { + const items = notesFromChangelogs(parseChangelog(BACKEND_CHANGELOG), parseChangelog(FRONTEND_CHANGELOG), { + version: "1.2.0", + since: "1.1.0", + }); + expect(items.map((i) => i.title)).toContain("Load listings faster"); + expect(items.map((i) => i.title)).not.toContain("Old feature"); + expect(items.map((i) => i.title)).not.toContain("Old frontend feature"); + }); + + it("leaves out a frontend release newer than the one running", () => { + const items = notesFromChangelogs(parseChangelog(BACKEND_CHANGELOG), parseChangelog(FRONTEND_CHANGELOG), { + version: "1.2.0", + frontendVersion: "1.2.0", + }); + expect(items.map((i) => i.title)).not.toContain("Pick an accent colour"); + }); + + it("resolves curated screenshots against the file and drops unsafe ones", () => { + const notes = parseCuratedNotes( + { + version: "1.2.0", + items: [ + { + type: "feature", + title: "Accents", + images: [ + { src: "screenshots/accent.png", caption: "Graphite" }, + { src: "https://example.com/a.png" }, + { src: "javascript:alert(1)" }, + ], + }, + { type: "fix", title: "A fix", highlight: true, code: "claude mcp add thingport" }, + ], + }, + "https://raw.example.com/repo/main/release-notes/latest-release-notes.json", + ); + expect(notes?.items[0].images).toEqual([ + { url: "https://raw.example.com/repo/main/release-notes/screenshots/accent.png", caption: "Graphite" }, + { url: "https://example.com/a.png", caption: null }, + ]); + expect(notes?.items[1]).toMatchObject({ highlight: true, code: "claude mcp add thingport" }); + expect(notes?.items[0]).toMatchObject({ highlight: false, code: null }); + expect(parseCuratedNotes({ version: "next", items: [] }, "https://x.example/a.json")).toBeNull(); + }); +}); + +describe("version update notification", () => { + const app = createApp(); + const run = Date.now(); + let user: { id: string; token: string }; + const updates = () => prisma.notification.findMany({ where: { userId: user.id, type: "system" } }); + + beforeAll(async () => { + const email = `release-notes-${run}@example.com`; + const res = await request(app).post("/api/register").send({ displayName: "R", email, password: "password123" }); + const login = await request(app).post("/api/login").send({ email, password: "password123" }); + user = { id: res.body.user.id as string, token: login.body.token as string }; + }); + + beforeEach(async () => { + await prisma.notification.deleteMany({ where: { userId: user.id } }); + }); + + afterAll(async () => { + await prisma.setting.deleteMany({ where: { key: "last_run_version" } }); + await prisma.user.deleteMany({ where: { id: user.id } }); + }); + + it("tells users once when a newer version starts, but not on a restart or a downgrade", async () => { + await prisma.setting.upsert({ + where: { key: "last_run_version" }, + create: { key: "last_run_version", value: "1.1.0" }, + update: { value: "1.1.0" }, + }); + expect(await announceVersionUpdate("1.2.0")).toBe(true); + const [notification] = await updates(); + expect(notification.internalPath).toBe("?whats-new=1.2.0&since=1.1.0"); + + expect(await announceVersionUpdate("1.2.0")).toBe(false); + expect(await announceVersionUpdate("1.1.5")).toBe(false); + expect(await updates()).toHaveLength(1); + }); + + it("tells existing users when the version that ran before isn't known", async () => { + await prisma.setting.deleteMany({ where: { key: "last_run_version" } }); + expect(await announceVersionUpdate("1.2.0")).toBe(true); + expect((await updates())[0].internalPath).toBe("?whats-new=1.2.0"); + }); +}); diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 8f97962..60a3377 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -256,6 +256,50 @@ ordinary notification (the System tab). To post one, add an entry to `entries` a - Entries older than 90 days aren't sent, so old ones can stay in the file or be removed. - `SYSTEM_NOTIFICATIONS_URL` points a backend at another file; an empty value turns fetching off. +## Release notes (What's new) + +The first time a backend starts on a newer version than it last ran, every user gets a notification that opens +the What's new dialog (also in the user menu). A fresh install isn't told. There's no need to add a "Welcome to +X.Y.Z" entry to `system-notifications.json` for this any more. + +The dialog fetches its content from `main`, so it can be written after the release is out: + +1. [`release-notes/latest-release-notes.json`](../release-notes/latest-release-notes.json) when its `version` is the + instance's backend version. +2. Otherwise `backend/CHANGELOG.md` for that version, with the `frontend/CHANGELOG.md` release that went out with + it. An instance that skipped versions gets every release since the one it ran before, up to five. + +To write the notes for a release, replace the file's contents: + +```json +{ + "version": "1.2.0", + "title": "Optional headline", + "summary": "Optional sentence or two under it.", + "items": [ + { + "type": "feature", + "highlight": true, + "title": "Pick an accent colour", + "description": "Optional detail.", + "code": "claude mcp add ...", + "images": [{ "src": "screenshots/1.2.0-accent.webp", "caption": "Optional caption" }], + "url": "https://github.com/TautvydasDerzinskas/Thingport/pull/123" + } + ] +} +``` + +- `type` is `breaking`, `feature`, `improvement` or `fix`; the dialog groups them in that order. +- `highlight: true` shows the item as a large card above the grouped list; keep it to the release's headline + features. +- `code` is a command shown in a box with a copy button, e.g. how to connect something. +- `images` are relative to the file (put them in `release-notes/screenshots/`, named after the version) or full + `https://` URLs; up to six per item. Crop them to the control being shown rather than the whole page; WebP keeps them + small. Everything but `type` and `title` is optional. +- A file that doesn't parse, or names another version, falls back to the changelog. +- `RELEASE_NOTES_URL` points a backend at another copy of the repo files; an empty value turns fetching off. + ## Related documents - [CONTRIBUTING.md](../CONTRIBUTING.md) -- branch, commit and pull request workflow diff --git a/frontend/src/api/releaseNotes.ts b/frontend/src/api/releaseNotes.ts new file mode 100644 index 0000000..7d55be6 --- /dev/null +++ b/frontend/src/api/releaseNotes.ts @@ -0,0 +1,41 @@ +import { authHeaders } from "../utils/auth"; +import { apiBase, assertOk } from "./client"; +import { FRONTEND_VERSION } from "./settings"; + +export type ReleaseNoteKind = "breaking" | "feature" | "improvement" | "fix"; + +export type ReleaseNoteItem = { + kind: ReleaseNoteKind; + title: string; + description: string | null; + scope: string | null; + images: { url: string; caption: string | null }[]; + /** A command to copy. */ + code: string | null; + /** Shown as a large card above the rest. */ + highlight: boolean; + url: string | null; +}; + +export type ReleaseNotes = { + version: string; + /** "curated" from release-notes/latest-release-notes.json, "changelog" from CHANGELOG.md, "none" when unreachable. */ + source: "curated" | "changelog" | "none"; + title: string | null; + summary: string | null; + items: ReleaseNoteItem[]; + releases_url: string; +}; + +export const releaseNotesApi = { + /** The backend's own version without `version`; `since` widens the notes to every release after it. */ + get: async (options: { version?: string | null; since?: string | null } = {}): Promise => { + const params = new URLSearchParams(); + if (options.version) params.set("version", options.version); + if (options.since) params.set("since", options.since); + if (FRONTEND_VERSION) params.set("frontend", FRONTEND_VERSION); + const res = await fetch(`${apiBase()}/release-notes?${params}`, { headers: authHeaders() }); + assertOk(res, "Failed to load what's new"); + return res.json(); + }, +}; diff --git a/frontend/src/components/Layout/AppLayout.tsx b/frontend/src/components/Layout/AppLayout.tsx index 38068fd..1604ac3 100644 --- a/frontend/src/components/Layout/AppLayout.tsx +++ b/frontend/src/components/Layout/AppLayout.tsx @@ -16,6 +16,7 @@ import BackToTopButton from "./BackToTopButton"; import { ConfirmProvider } from "../ConfirmProvider"; import { ToastProvider } from "../ToastProvider"; import { ProviderAboutProvider } from "../providerAbout/ProviderAboutProvider"; +import { WhatsNewProvider } from "../whatsNew/WhatsNewProvider"; import { PageHeaderContext, type PageHeader } from "./PageHeaderContext"; import { NavigationHistoryProvider, useSmartBack } from "./NavigationHistoryContext"; import { ImportJobProvider } from "./ImportJobContext"; @@ -238,7 +239,9 @@ export default function AppLayout({ muiTheme, onUnauthorized, ...shellProps }: A - + + + diff --git a/frontend/src/components/Layout/UserMenu.tsx b/frontend/src/components/Layout/UserMenu.tsx index 806a453..60af4de 100644 --- a/frontend/src/components/Layout/UserMenu.tsx +++ b/frontend/src/components/Layout/UserMenu.tsx @@ -15,6 +15,7 @@ import PersonIcon from "@mui/icons-material/Person"; import ViewInArIcon from "@mui/icons-material/ViewInAr"; import SettingsIcon from "@mui/icons-material/Settings"; import LogoutIcon from "@mui/icons-material/Logout"; +import NewReleasesOutlinedIcon from "@mui/icons-material/NewReleasesOutlined"; import type { AuthUser } from "../../api/auth"; import type { ConfigurationTab } from "./ConfigurationDialog"; import { IMPORT_PROVIDER_INFO } from "../../constants/importProviders"; @@ -24,6 +25,7 @@ import { useGravatarUrl } from "../../hooks/useGravatarUrl"; import { settingsApi, type Cults3dSettings } from "../../api/settings"; import { cults3dReady, cults3dStatus } from "./ConfigurationDialog/cults3dStatus"; import { useDisabledProviders } from "../../hooks/useDisabledProviders"; +import { useWhatsNew } from "../whatsNew/WhatsNewProvider"; type ServiceChipDef = { key: @@ -55,6 +57,7 @@ type Props = { export function UserMenu({ user, onOpenConfiguration, onOpenProfile, onLogout }: Props) { const { t } = useTranslation(["app", "common"]); const navigate = useNavigate(); + const openWhatsNew = useWhatsNew(); const avatarUrl = useGravatarUrl(user?.email, 128); const [anchorEl, setAnchorEl] = React.useState(null); const [makerworldConfigured, setMakerworldConfigured] = React.useState(false); @@ -309,6 +312,17 @@ export function UserMenu({ user, onOpenConfiguration, onOpenProfile, onLogout }: {t("configuration.title")} + { + closeMenu(); + openWhatsNew(); + }} + > + + + + {t("whatsNew.label")} + {services.map((svc) => ( diff --git a/frontend/src/components/whatsNew/WhatsNewDialog.tsx b/frontend/src/components/whatsNew/WhatsNewDialog.tsx new file mode 100644 index 0000000..961ee8a --- /dev/null +++ b/frontend/src/components/whatsNew/WhatsNewDialog.tsx @@ -0,0 +1,577 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { keyframes } from "@emotion/react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import ButtonBase from "@mui/material/ButtonBase"; +import Dialog from "@mui/material/Dialog"; +import IconButton from "@mui/material/IconButton"; +import Link from "@mui/material/Link"; +import Stack from "@mui/material/Stack"; +import Tooltip from "@mui/material/Tooltip"; +import Typography from "@mui/material/Typography"; +import { alpha, useTheme, type Theme } from "@mui/material/styles"; +import AutoAwesomeOutlinedIcon from "@mui/icons-material/AutoAwesomeOutlined"; +import BoltOutlinedIcon from "@mui/icons-material/BoltOutlined"; +import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined"; +import CheckIcon from "@mui/icons-material/Check"; +import CloseIcon from "@mui/icons-material/Close"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; +import NewReleasesOutlinedIcon from "@mui/icons-material/NewReleasesOutlined"; +import OpenInNewIcon from "@mui/icons-material/OpenInNew"; +import WarningAmberRoundedIcon from "@mui/icons-material/WarningAmberRounded"; +import { releaseNotesApi, type ReleaseNoteItem, type ReleaseNoteKind, type ReleaseNotes } from "../../api/releaseNotes"; +import { ListRowSkeletons } from "../ListSkeleton"; + +export type WhatsNewRequest = { + /** This instance's version when null. */ + version: string | null; + /** The version updated from, to cover every release since. */ + since: string | null; +}; + +const KINDS: ReleaseNoteKind[] = ["breaking", "feature", "improvement", "fix"]; +const KIND_STYLE: Record string }> = { + breaking: { Icon: WarningAmberRoundedIcon, color: (theme) => theme.palette.error.main }, + feature: { Icon: AutoAwesomeOutlinedIcon, color: (theme) => theme.palette.primary.main }, + improvement: { Icon: BoltOutlinedIcon, color: (theme) => theme.palette.info.main }, + fix: { Icon: BuildOutlinedIcon, color: (theme) => theme.palette.success.main }, +}; +const SHOT_HEIGHT = 280; +// A long list of one-line fixes is cut short until asked for. +const FIXES_SHOWN = 6; + +const fadeUp = keyframes` + from { transform: translateY(6px); opacity: 0; } + to { transform: none; opacity: 1; } +`; +const enter = (index: number) => ({ + animation: `${fadeUp} 220ms ease-out both`, + animationDelay: `${Math.min(index, 10) * 30}ms`, + "@media (prefers-reduced-motion: reduce)": { animation: "none" }, +}); + +function Screenshots({ + item, + onZoom, +}: { + item: ReleaseNoteItem; + onZoom: (image: ReleaseNoteItem["images"][0]) => void; +}) { + const { t } = useTranslation("app"); + if (!item.images.length) return null; + return ( + + {item.images.map((image) => ( + onZoom(image)} + sx={{ + display: "block", + borderRadius: 1.5, + overflow: "hidden", + border: 1, + borderColor: "divider", + textAlign: "left", + "& img": { transition: "transform 200ms ease-out" }, + "&:hover img": { transform: "scale(1.02)" }, + "&:focus-visible": { outline: (theme) => `2px solid ${theme.palette.primary.main}`, outlineOffset: 2 }, + "@media (prefers-reduced-motion: reduce)": { "&:hover img": { transform: "none" } }, + }} + > + + {image.caption && ( + + {image.caption} + + )} + + ))} + + ); +} + +function CodeBlock({ code }: { code: string }) { + const { t } = useTranslation("app"); + const [copied, setCopied] = React.useState(false); + React.useEffect(() => { + if (!copied) return; + const timer = window.setTimeout(() => setCopied(false), 1500); + return () => window.clearTimeout(timer); + }, [copied]); + const copy = () => + navigator.clipboard + ?.writeText(code) + .then(() => setCopied(true)) + .catch(() => undefined); + return ( + + + {code} + + + + {copied ? : } + + + + ); +} + +/** A release's headline change, above the grouped list. */ +function HighlightCard({ + item, + index, + onZoom, +}: { + item: ReleaseNoteItem; + index: number; + onZoom: (image: ReleaseNoteItem["images"][0]) => void; +}) { + const { t } = useTranslation("app"); + const { Icon, color } = KIND_STYLE[item.kind]; + return ( + ({ + ...enter(index), + p: 2, + borderRadius: 2, + border: `1px solid ${alpha(color(theme), 0.35)}`, + background: `linear-gradient(150deg, ${alpha(color(theme), 0.12)} 0%, ${alpha(color(theme), 0.02)} 60%)`, + })} + > + + ({ + display: "inline-flex", + alignItems: "center", + gap: 0.5, + px: 1, + py: 0.25, + borderRadius: 999, + fontSize: 11, + fontWeight: 700, + letterSpacing: "0.06em", + textTransform: "uppercase", + color: color(theme), + bgcolor: alpha(color(theme), 0.14), + })} + > + + {t("whatsNew.highlight")} + + + muiTheme.thingport.headingText }} + > + {item.title} + + {item.description && ( + + {item.description} + + )} + {item.code && } + + {item.url && ( + + {t("whatsNew.learnMore")} + + + )} + + ); +} + +function NoteRow({ + item, + index, + curated, + onZoom, +}: { + item: ReleaseNoteItem; + index: number; + curated: boolean; + onZoom: (image: ReleaseNoteItem["images"][0]) => void; +}) { + const { t } = useTranslation("app"); + const { color } = KIND_STYLE[item.kind]; + return ( + + + {item.scope && ( + + {item.scope} + + )} + {item.title} + + {item.description && ( + + {item.description} + + )} + {curated && item.url && ( + + {t("whatsNew.learnMore")} + + + )} + {item.code && } + + + ); +} + +function Section({ + kind, + items, + curated, + offset, + onZoom, +}: { + kind: ReleaseNoteKind; + items: ReleaseNoteItem[]; + curated: boolean; + offset: number; + onZoom: (image: ReleaseNoteItem["images"][0]) => void; +}) { + const { t } = useTranslation("app"); + const [expanded, setExpanded] = React.useState(false); + const { Icon, color } = KIND_STYLE[kind]; + const cut = kind === "fix" && !expanded && items.length > FIXES_SHOWN + 1; + const shown = cut ? items.slice(0, FIXES_SHOWN) : items; + return ( + + + ({ + width: 24, + height: 24, + borderRadius: "7px", + display: "grid", + placeItems: "center", + color: color(theme), + bgcolor: alpha(color(theme), 0.14), + })} + > + + + + {t(`whatsNew.kinds.${kind}`)} + + + {items.length} + + + + {shown.map((item, index) => ( + + ))} + + {kind === "fix" && items.length > FIXES_SHOWN + 1 && ( + + )} + + ); +} + +type Props = { + /** What to show, or null when closed. */ + request: WhatsNewRequest | null; + onClose: () => void; +}; + +/** The changes in this version: the hand-written notes when there are some for it, otherwise its changelog. */ +export default function WhatsNewDialog({ request, onClose }: Props) { + const { t } = useTranslation(["app", "common"]); + const theme = useTheme(); + const [notes, setNotes] = React.useState(null); + const [failed, setFailed] = React.useState(false); + const [zoomed, setZoomed] = React.useState(null); + + React.useEffect(() => { + if (!request) return; + let active = true; + setNotes(null); + setFailed(false); + releaseNotesApi + .get(request) + .then((result) => active && setNotes(result)) + .catch(() => active && setFailed(true)); + return () => { + active = false; + }; + }, [request]); + + const accent = theme.palette.primary.main; + const loading = !notes && !failed; + const empty = failed || (notes !== null && notes.items.length === 0); + const highlights = notes?.items.filter((item) => item.highlight) ?? []; + const sections = KINDS.map((kind) => ({ + kind, + items: notes?.items.filter((item) => item.kind === kind && !item.highlight) ?? [], + })).filter((section) => section.items.length > 0); + const offsets = sections.map((_, index) => + sections + .slice(0, index) + .reduce((sum, section) => sum + Math.min(section.items.length, FIXES_SHOWN), highlights.length), + ); + // Screenshots need the room; a plain list of changes reads better narrow. + const wide = notes?.items.some((item) => item.images.length > 0) ?? false; + const releasesUrl = notes?.releases_url ?? "https://github.com/TautvydasDerzinskas/Thingport/releases"; + + return ( + <> + + + + + + + + + + + + {notes ? t("whatsNew.versionLabel", { version: notes.version }) : t("whatsNew.label")} + + muiTheme.thingport.headingText }} + > + {notes?.title ?? t("whatsNew.label")} + + + + + + + {loading && } + {empty && ( + + + {failed || notes?.source === "none" ? t("whatsNew.unavailable") : t("whatsNew.empty")} + + + + )} + {notes && !empty && ( + + {notes.summary && ( + + {notes.summary} + + )} + {highlights.map((item, index) => ( + + ))} + {sections.map((section, index) => ( +
+ ))} + + )} + + + + + {t("whatsNew.allReleases")} + + + + +
+ + setZoomed(null)} + maxWidth="lg" + aria-label={zoomed?.caption ?? t("whatsNew.zoom")} + slotProps={{ paper: { sx: { overflow: "hidden", position: "relative" } } }} + > + setZoomed(null)} + size="small" + sx={{ + position: "absolute", + right: 8, + top: 8, + bgcolor: (muiTheme) => alpha(muiTheme.palette.background.paper, 0.8), + "&:hover": { bgcolor: "background.paper" }, + }} + > + + + {zoomed && ( + + )} + {zoomed?.caption && ( + + {zoomed.caption} + + )} + + + ); +} diff --git a/frontend/src/components/whatsNew/WhatsNewProvider.tsx b/frontend/src/components/whatsNew/WhatsNewProvider.tsx new file mode 100644 index 0000000..7904f00 --- /dev/null +++ b/frontend/src/components/whatsNew/WhatsNewProvider.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import { useSearchParams } from "react-router-dom"; +import WhatsNewDialog, { type WhatsNewRequest } from "./WhatsNewDialog"; + +type OpenWhatsNew = (request?: Partial) => void; + +const WhatsNewContext = React.createContext(null); + +/** One What's new dialog for the app. The update notification opens it with "?whats-new=&since=", + * which works on any page and is taken off the URL once read, so going back doesn't reopen it. */ +export function WhatsNewProvider({ children }: { children: React.ReactNode }) { + const [request, setRequest] = React.useState(null); + const [searchParams, setSearchParams] = useSearchParams(); + + React.useEffect(() => { + const version = searchParams.get("whats-new"); + if (version === null) return; + setRequest({ version: version || null, since: searchParams.get("since") }); + const rest = new URLSearchParams(searchParams); + rest.delete("whats-new"); + rest.delete("since"); + setSearchParams(rest, { replace: true }); + }, [searchParams, setSearchParams]); + + const open = React.useCallback( + (next) => setRequest({ version: next?.version ?? null, since: next?.since ?? null }), + [], + ); + + return ( + + {children} + setRequest(null)} /> + + ); +} + +/** Opens What's new; this instance's version unless told another. A no-op outside the provider. */ +export function useWhatsNew(): OpenWhatsNew { + return React.useContext(WhatsNewContext) ?? (() => undefined); +} diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index c79822c..f10ee89 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2176,6 +2176,28 @@ ] } } + }, + "whatsNew": { + "label": "What's new", + "versionLabel": "Thingport {{version}}", + "kinds": { + "breaking": "Breaking changes", + "feature": "New features", + "improvement": "Improvements", + "fix": "Fixes" + }, + "showAll": "Show all {{total}}", + "showFewer": "Show fewer", + "learnMore": "Learn more", + "allReleases": "All releases", + "gotIt": "Got it", + "unavailable": "Couldn't load the release notes.", + "empty": "No release notes for this version yet.", + "openGithub": "Open on GitHub", + "zoom": "Enlarge screenshot", + "highlight": "Highlight", + "copy": "Copy", + "copied": "Copied" } }, "common": { diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index baf4658..20e1bba 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -2176,6 +2176,28 @@ ] } } + }, + "whatsNew": { + "label": "Novedades", + "versionLabel": "Thingport {{version}}", + "kinds": { + "breaking": "Cambios incompatibles", + "feature": "Nuevas funciones", + "improvement": "Mejoras", + "fix": "Correcciones" + }, + "showAll": "Mostrar todas ({{total}})", + "showFewer": "Mostrar menos", + "learnMore": "Más información", + "allReleases": "Todas las versiones", + "gotIt": "Entendido", + "unavailable": "No se pudieron cargar las notas de la versión.", + "empty": "Aún no hay notas para esta versión.", + "openGithub": "Abrir en GitHub", + "zoom": "Ampliar captura", + "highlight": "Destacado", + "copy": "Copiar", + "copied": "Copiado" } }, "common": { diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 6bd72d5..e87a238 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -2189,6 +2189,28 @@ ] } } + }, + "whatsNew": { + "label": "Nouveautés", + "versionLabel": "Thingport {{version}}", + "kinds": { + "breaking": "Changements incompatibles", + "feature": "Nouvelles fonctionnalités", + "improvement": "Améliorations", + "fix": "Corrections" + }, + "showAll": "Tout afficher ({{total}})", + "showFewer": "Afficher moins", + "learnMore": "En savoir plus", + "allReleases": "Toutes les versions", + "gotIt": "Compris", + "unavailable": "Impossible de charger les notes de version.", + "empty": "Pas encore de notes pour cette version.", + "openGithub": "Ouvrir sur GitHub", + "zoom": "Agrandir la capture", + "highlight": "À la une", + "copy": "Copier", + "copied": "Copié" } }, "common": { diff --git a/frontend/src/i18n/locales/lt.json b/frontend/src/i18n/locales/lt.json index 86daf43..d238aba 100644 --- a/frontend/src/i18n/locales/lt.json +++ b/frontend/src/i18n/locales/lt.json @@ -2242,6 +2242,28 @@ ] } } + }, + "whatsNew": { + "label": "Kas naujo", + "versionLabel": "Thingport {{version}}", + "kinds": { + "breaking": "Nesuderinami pakeitimai", + "feature": "Naujos funkcijos", + "improvement": "Patobulinimai", + "fix": "Pataisymai" + }, + "showAll": "Rodyti visus ({{total}})", + "showFewer": "Rodyti mažiau", + "learnMore": "Daugiau", + "allReleases": "Visos versijos", + "gotIt": "Supratau", + "unavailable": "Nepavyko įkelti versijos naujienų.", + "empty": "Šiai versijai naujienų dar nėra.", + "openGithub": "Atidaryti GitHub", + "zoom": "Padidinti ekrano vaizdą", + "highlight": "Svarbiausia", + "copy": "Kopijuoti", + "copied": "Nukopijuota" } }, "common": {