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": {