mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 22:22:24 -05:00
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.
This commit is contained in:
1 parent
f7870d0067
commit
aebc0ac97e
18 files changed
+1351
-3
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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<boolean> {
|
||||
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<ReleaseNotes, "releases_url"> | 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<string, ReleaseNoteKind> = {
|
||||
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<T extends ReleaseNoteItem>(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<string>();
|
||||
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<string, { at: number; text: string | null }>();
|
||||
|
||||
/** 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<string | null> {
|
||||
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<ReleaseNotes | null> {
|
||||
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 };
|
||||
}
|
||||
@@ -39,7 +39,7 @@ const entrySchema = z.object({
|
||||
});
|
||||
export type SystemNotificationEntry = z.infer<typeof entrySchema>;
|
||||
|
||||
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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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<ReleaseNotes> => {
|
||||
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();
|
||||
},
|
||||
};
|
||||
@@ -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
|
||||
<ToastProvider>
|
||||
<NotificationsProvider onUnauthorized={onUnauthorized}>
|
||||
<ProviderAboutProvider>
|
||||
<AppLayoutShell onUnauthorized={onUnauthorized} {...shellProps} />
|
||||
<WhatsNewProvider>
|
||||
<AppLayoutShell onUnauthorized={onUnauthorized} {...shellProps} />
|
||||
</WhatsNewProvider>
|
||||
</ProviderAboutProvider>
|
||||
</NotificationsProvider>
|
||||
</ToastProvider>
|
||||
|
||||
@@ -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<HTMLElement | null>(null);
|
||||
const [makerworldConfigured, setMakerworldConfigured] = React.useState(false);
|
||||
@@ -309,6 +312,17 @@ export function UserMenu({ user, onOpenConfiguration, onOpenProfile, onLogout }:
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t("configuration.title")}</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
closeMenu();
|
||||
openWhatsNew();
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<NewReleasesOutlinedIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t("whatsNew.label")}</ListItemText>
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<Box sx={{ px: 1.5, py: 1, display: "flex", flexWrap: "wrap", gap: 0.5, maxWidth: 260 }}>
|
||||
{services.map((svc) => (
|
||||
|
||||
@@ -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<ReleaseNoteKind, { Icon: typeof BoltOutlinedIcon; color: (theme: Theme) => 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 (
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
alignItems: "start",
|
||||
gap: 1,
|
||||
mt: 1,
|
||||
// One picture keeps to its own width, so a dialog cut-out isn't blown up to fill the row.
|
||||
gridTemplateColumns:
|
||||
item.images.length === 1 ? "minmax(0, max-content)" : "repeat(auto-fit, minmax(220px, 1fr))",
|
||||
}}
|
||||
>
|
||||
{item.images.map((image) => (
|
||||
<ButtonBase
|
||||
key={image.url}
|
||||
aria-label={image.caption ?? t("whatsNew.zoom")}
|
||||
onClick={() => 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" } },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={image.url}
|
||||
alt={image.caption ?? item.title}
|
||||
loading="lazy"
|
||||
sx={{
|
||||
display: "block",
|
||||
width: item.images.length === 1 ? "auto" : "100%",
|
||||
maxWidth: "100%",
|
||||
height: "auto",
|
||||
maxHeight: SHOT_HEIGHT,
|
||||
objectFit: "contain",
|
||||
bgcolor: "action.hover",
|
||||
}}
|
||||
/>
|
||||
{image.caption && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block", px: 1, py: 0.5 }}>
|
||||
{image.caption}
|
||||
</Typography>
|
||||
)}
|
||||
</ButtonBase>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box
|
||||
sx={{
|
||||
position: "relative",
|
||||
mt: 1,
|
||||
borderRadius: 1.5,
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
bgcolor: "action.hover",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="pre"
|
||||
sx={{
|
||||
m: 0,
|
||||
p: 1.25,
|
||||
pr: 5,
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.6,
|
||||
whiteSpace: "pre-wrap",
|
||||
overflowWrap: "anywhere",
|
||||
}}
|
||||
>
|
||||
{code}
|
||||
</Box>
|
||||
<Tooltip title={copied ? t("whatsNew.copied") : t("whatsNew.copy")}>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={t("whatsNew.copy")}
|
||||
onClick={copy}
|
||||
sx={{ position: "absolute", top: 4, right: 4 }}
|
||||
>
|
||||
{copied ? <CheckIcon sx={{ fontSize: 16 }} color="success" /> : <ContentCopyIcon sx={{ fontSize: 16 }} />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<Box
|
||||
component="article"
|
||||
sx={(theme) => ({
|
||||
...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%)`,
|
||||
})}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 0.75 }}>
|
||||
<Box
|
||||
sx={(theme) => ({
|
||||
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),
|
||||
})}
|
||||
>
|
||||
<Icon sx={{ fontSize: 13 }} />
|
||||
{t("whatsNew.highlight")}
|
||||
</Box>
|
||||
</Stack>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
fontWeight={700}
|
||||
sx={{ lineHeight: 1.3, color: (muiTheme) => muiTheme.thingport.headingText }}
|
||||
>
|
||||
{item.title}
|
||||
</Typography>
|
||||
{item.description && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||
{item.description}
|
||||
</Typography>
|
||||
)}
|
||||
{item.code && <CodeBlock code={item.code} />}
|
||||
<Screenshots item={item} onZoom={onZoom} />
|
||||
{item.url && (
|
||||
<Link
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="caption"
|
||||
underline="hover"
|
||||
sx={{ display: "inline-flex", alignItems: "center", gap: 0.25, mt: 1 }}
|
||||
>
|
||||
{t("whatsNew.learnMore")}
|
||||
<OpenInNewIcon sx={{ fontSize: 12 }} />
|
||||
</Link>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box
|
||||
component="li"
|
||||
sx={{
|
||||
...enter(index),
|
||||
position: "relative",
|
||||
listStyle: "none",
|
||||
pl: 2,
|
||||
py: 0.5,
|
||||
"&::before": {
|
||||
content: '""',
|
||||
position: "absolute",
|
||||
left: 2,
|
||||
top: 13,
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: "50%",
|
||||
bgcolor: color,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" fontWeight={item.description || item.images.length ? 600 : 400}>
|
||||
{item.scope && (
|
||||
<Box component="span" sx={{ color: "text.secondary", fontWeight: 600, mr: 0.75 }}>
|
||||
{item.scope}
|
||||
</Box>
|
||||
)}
|
||||
{item.title}
|
||||
</Typography>
|
||||
{item.description && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}>
|
||||
{item.description}
|
||||
</Typography>
|
||||
)}
|
||||
{curated && item.url && (
|
||||
<Link
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="caption"
|
||||
underline="hover"
|
||||
sx={{ display: "inline-flex", alignItems: "center", gap: 0.25, mt: 0.25 }}
|
||||
>
|
||||
{t("whatsNew.learnMore")}
|
||||
<OpenInNewIcon sx={{ fontSize: 12 }} />
|
||||
</Link>
|
||||
)}
|
||||
{item.code && <CodeBlock code={item.code} />}
|
||||
<Screenshots item={item} onZoom={onZoom} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box component="section" aria-labelledby={`whats-new-${kind}`}>
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 0.5 }}>
|
||||
<Box
|
||||
sx={(theme) => ({
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: "7px",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
color: color(theme),
|
||||
bgcolor: alpha(color(theme), 0.14),
|
||||
})}
|
||||
>
|
||||
<Icon sx={{ fontSize: 15 }} />
|
||||
</Box>
|
||||
<Typography id={`whats-new-${kind}`} variant="subtitle2" fontWeight={700}>
|
||||
{t(`whatsNew.kinds.${kind}`)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.disabled">
|
||||
{items.length}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Box component="ul" sx={{ m: 0, p: 0 }}>
|
||||
{shown.map((item, index) => (
|
||||
<NoteRow key={item.url ?? item.title} item={item} index={offset + index} curated={curated} onZoom={onZoom} />
|
||||
))}
|
||||
</Box>
|
||||
{kind === "fix" && items.length > FIXES_SHOWN + 1 && (
|
||||
<Button size="small" onClick={() => setExpanded((value) => !value)} sx={{ ml: 1, mt: 0.25 }}>
|
||||
{expanded ? t("whatsNew.showFewer") : t("whatsNew.showAll", { total: items.length })}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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<ReleaseNotes | null>(null);
|
||||
const [failed, setFailed] = React.useState(false);
|
||||
const [zoomed, setZoomed] = React.useState<ReleaseNoteItem["images"][0] | null>(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 (
|
||||
<>
|
||||
<Dialog
|
||||
open={Boolean(request)}
|
||||
onClose={onClose}
|
||||
fullWidth
|
||||
maxWidth={wide ? "md" : "sm"}
|
||||
aria-labelledby="whats-new-title"
|
||||
slotProps={{ paper: { sx: { overflow: "hidden" } } }}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: "relative",
|
||||
flexShrink: 0,
|
||||
px: 3,
|
||||
pt: 2.5,
|
||||
pb: 2,
|
||||
background: `linear-gradient(160deg, ${alpha(accent, 0.2)} 0%, ${alpha(accent, 0.03)} 70%)`,
|
||||
borderBottom: `1px solid ${alpha(accent, 0.25)}`,
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
aria-label={t("common:close")}
|
||||
onClick={onClose}
|
||||
size="small"
|
||||
sx={{ position: "absolute", right: 12, top: 12 }}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ pr: 5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
flexShrink: 0,
|
||||
borderRadius: "11px",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
color: "primary.contrastText",
|
||||
bgcolor: "primary.main",
|
||||
boxShadow: `0 2px 8px ${alpha(accent, 0.35)}`,
|
||||
}}
|
||||
>
|
||||
<NewReleasesOutlinedIcon />
|
||||
</Box>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="overline" color="text.secondary" sx={{ lineHeight: 1.6, letterSpacing: "0.08em" }}>
|
||||
{notes ? t("whatsNew.versionLabel", { version: notes.version }) : t("whatsNew.label")}
|
||||
</Typography>
|
||||
<Typography
|
||||
id="whats-new-title"
|
||||
variant="h6"
|
||||
fontWeight={700}
|
||||
sx={{ lineHeight: 1.25, color: (muiTheme) => muiTheme.thingport.headingText }}
|
||||
>
|
||||
{notes?.title ?? t("whatsNew.label")}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ px: 3, pt: 2, pb: 2, overflowY: "auto", flex: "1 1 auto", minHeight: 0 }}>
|
||||
{loading && <ListRowSkeletons count={5} lead="circle" leadSize={24} twoLines />}
|
||||
{empty && (
|
||||
<Stack alignItems="center" spacing={1.5} sx={{ py: 3, textAlign: "center", ...enter(0) }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{failed || notes?.source === "none" ? t("whatsNew.unavailable") : t("whatsNew.empty")}
|
||||
</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
href={releasesUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
endIcon={<OpenInNewIcon sx={{ fontSize: 14 }} />}
|
||||
>
|
||||
{t("whatsNew.openGithub")}
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
{notes && !empty && (
|
||||
<Stack spacing={2}>
|
||||
{notes.summary && (
|
||||
<Typography variant="body2" color="text.secondary" sx={enter(0)}>
|
||||
{notes.summary}
|
||||
</Typography>
|
||||
)}
|
||||
{highlights.map((item, index) => (
|
||||
<HighlightCard key={`${notes.version}-${item.title}`} item={item} index={index} onZoom={setZoomed} />
|
||||
))}
|
||||
{sections.map((section, index) => (
|
||||
<Section
|
||||
key={`${notes.version}-${section.kind}`}
|
||||
kind={section.kind}
|
||||
items={section.items}
|
||||
curated={notes.source === "curated"}
|
||||
offset={offsets[index]}
|
||||
onZoom={setZoomed}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
sx={{ px: 3, py: 1.5, borderTop: 1, borderColor: "divider", flexShrink: 0 }}
|
||||
>
|
||||
<Link
|
||||
href={releasesUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="body2"
|
||||
underline="hover"
|
||||
sx={{ display: "inline-flex", alignItems: "center", gap: 0.5 }}
|
||||
>
|
||||
{t("whatsNew.allReleases")}
|
||||
<OpenInNewIcon sx={{ fontSize: 14 }} />
|
||||
</Link>
|
||||
<Button variant="contained" onClick={onClose}>
|
||||
{t("whatsNew.gotIt")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(zoomed)}
|
||||
onClose={() => setZoomed(null)}
|
||||
maxWidth="lg"
|
||||
aria-label={zoomed?.caption ?? t("whatsNew.zoom")}
|
||||
slotProps={{ paper: { sx: { overflow: "hidden", position: "relative" } } }}
|
||||
>
|
||||
<IconButton
|
||||
aria-label={t("common:close")}
|
||||
onClick={() => setZoomed(null)}
|
||||
size="small"
|
||||
sx={{
|
||||
position: "absolute",
|
||||
right: 8,
|
||||
top: 8,
|
||||
bgcolor: (muiTheme) => alpha(muiTheme.palette.background.paper, 0.8),
|
||||
"&:hover": { bgcolor: "background.paper" },
|
||||
}}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
{zoomed && (
|
||||
<Box
|
||||
component="img"
|
||||
src={zoomed.url}
|
||||
alt={zoomed.caption ?? ""}
|
||||
sx={{ display: "block", maxWidth: "100%", maxHeight: "85vh", objectFit: "contain" }}
|
||||
/>
|
||||
)}
|
||||
{zoomed?.caption && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ px: 2, py: 1 }}>
|
||||
{zoomed.caption}
|
||||
</Typography>
|
||||
)}
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import WhatsNewDialog, { type WhatsNewRequest } from "./WhatsNewDialog";
|
||||
|
||||
type OpenWhatsNew = (request?: Partial<WhatsNewRequest>) => void;
|
||||
|
||||
const WhatsNewContext = React.createContext<OpenWhatsNew | null>(null);
|
||||
|
||||
/** One What's new dialog for the app. The update notification opens it with "?whats-new=<version>&since=<old>",
|
||||
* 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<WhatsNewRequest | null>(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<OpenWhatsNew>(
|
||||
(next) => setRequest({ version: next?.version ?? null, since: next?.since ?? null }),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<WhatsNewContext.Provider value={open}>
|
||||
{children}
|
||||
<WhatsNewDialog request={request} onClose={() => setRequest(null)} />
|
||||
</WhatsNewContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in new issue
Block a user