diff --git a/packages/app/src/components/dialog-release-notes.tsx b/packages/app/src/components/dialog-release-notes.tsx index c5a1e15c13..d6375dbbc4 100644 --- a/packages/app/src/components/dialog-release-notes.tsx +++ b/packages/app/src/components/dialog-release-notes.tsx @@ -4,6 +4,107 @@ import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" import { markReleaseNotesSeen } from "@/lib/release-notes" +const CHANGELOG_URL = "https://opencode.ai/changelog.json" + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +function getText(value: unknown): string | undefined { + if (typeof value === "string") { + const text = value.trim() + return text.length > 0 ? text : undefined + } + + if (!Array.isArray(value)) return + const parts = value.map((item) => (typeof item === "string" ? item.trim() : "")).filter((item) => item.length > 0) + if (parts.length === 0) return + return parts.join(" ") +} + +function normalizeRemoteUrl(url: string): string { + if (url.startsWith("https://") || url.startsWith("http://")) return url + if (url.startsWith("/")) return `https://opencode.ai${url}` + return `https://opencode.ai/${url}` +} + +function parseMedia(value: unknown): ReleaseFeature["media"] | undefined { + if (!isRecord(value)) return + + const type = getText(value.type)?.toLowerCase() + const src = getText(value.src) + if (!src) return + if (type !== "image" && type !== "video") return + + return { + type, + src: normalizeRemoteUrl(src), + alt: getText(value.alt), + } +} + +function parseFeature(value: unknown): ReleaseFeature | undefined { + if (!isRecord(value)) return + + const title = getText(value.title) ?? getText(value.name) ?? getText(value.heading) + const description = getText(value.description) ?? getText(value.body) ?? getText(value.text) + + if (!title) return + if (!description) return + + const tag = getText(value.tag) ?? getText(value.label) ?? "New" + + const media = (() => { + const parsed = parseMedia(value.media) + if (parsed) return parsed + + const alt = getText(value.alt) + const image = getText(value.image) + if (image) return { type: "image" as const, src: normalizeRemoteUrl(image), alt } + + const video = getText(value.video) + if (video) return { type: "video" as const, src: normalizeRemoteUrl(video), alt } + })() + + return { title, description, tag, media } +} + +function parseChangelog(value: unknown): ReleaseNote | undefined { + const releases = (() => { + if (Array.isArray(value)) return value + if (!isRecord(value)) return + if (Array.isArray(value.releases)) return value.releases + if (Array.isArray(value.versions)) return value.versions + if (Array.isArray(value.changelog)) return value.changelog + })() + + if (!releases) { + if (!isRecord(value)) return + if (!Array.isArray(value.highlights)) return + const features = value.highlights.map(parseFeature).filter((item): item is ReleaseFeature => item !== undefined) + if (features.length === 0) return + return { version: CURRENT_RELEASE.version, features: features.slice(0, 3) } + } + + const version = (() => { + const head = releases[0] + if (!isRecord(head)) return + return getText(head.version) ?? getText(head.tag_name) ?? getText(head.tag) ?? getText(head.name) + })() + + const features = releases + .flatMap((item) => { + if (!isRecord(item)) return [] + const highlights = item.highlights + if (!Array.isArray(highlights)) return [] + return highlights.map(parseFeature).filter((feature): feature is ReleaseFeature => feature !== undefined) + }) + .slice(0, 3) + + if (features.length === 0) return + return { version: version ?? CURRENT_RELEASE.version, features } +} + export interface ReleaseFeature { title: string description: string @@ -59,13 +160,13 @@ export const CURRENT_RELEASE: ReleaseNote = { export function DialogReleaseNotes(props: { release?: ReleaseNote }) { const dialog = useDialog() - const release = props.release ?? CURRENT_RELEASE + const [note, setNote] = createSignal(props.release ?? CURRENT_RELEASE) const [index, setIndex] = createSignal(0) - const feature = () => release.features[index()] - const total = release.features.length + const feature = () => note().features[index()] ?? note().features[0] ?? CURRENT_RELEASE.features[0]! + const total = () => note().features.length const isFirst = () => index() === 0 - const isLast = () => index() === total - 1 + const isLast = () => index() === total() - 1 function handleNext() { if (!isLast()) setIndex(index() + 1) @@ -97,6 +198,26 @@ export function DialogReleaseNotes(props: { release?: ReleaseNote }) { focusTrap?.focus() document.addEventListener("keydown", handleKeyDown) onCleanup(() => document.removeEventListener("keydown", handleKeyDown)) + + const controller = new AbortController() + fetch(CHANGELOG_URL, { + signal: controller.signal, + headers: { Accept: "application/json" }, + }) + .then((response) => (response.ok ? (response.json() as Promise) : undefined)) + .then((json) => { + if (!json) return + const parsed = parseChangelog(json) + if (!parsed) return + setNote({ + version: parsed.version, + features: parsed.features, + }) + setIndex(0) + }) + .catch(() => undefined) + + onCleanup(() => controller.abort()) }) // Refocus the trap when index changes to ensure escape always works @@ -144,16 +265,20 @@ export function DialogReleaseNotes(props: { release?: ReleaseNote }) { )} - {total > 1 && ( + {total() > 1 && (
- {release.features.map((_, i) => ( + {note().features.map((_, i) => (