import { createEffect, createSignal, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import { useDialog } from "@opencode-ai/ui/context/dialog" import { usePlatform } from "@/context/platform" import { useSettings } from "@/context/settings" import { persisted } from "@/utils/persist" import { DialogReleaseNotes, type Highlight } from "@/components/dialog-release-notes" const CHANGELOG_URL = "https://opencode.ai/changelog.json" type Store = { version?: string } type ParsedRelease = { tag?: string highlights: Highlight[] } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value) } function getText(value: unknown): string | undefined { if (typeof value === "string") { const text = value.trim() return text.length > 0 ? text : undefined } if (typeof value === "number") return String(value) return } function normalizeVersion(value: string | undefined) { const text = value?.trim() if (!text) return return text.startsWith("v") || text.startsWith("V") ? text.slice(1) : text } function parseMedia(value: unknown, alt: string): Highlight["media"] | undefined { if (!isRecord(value)) return const type = getText(value.type)?.toLowerCase() const src = getText(value.src) ?? getText(value.url) if (!src) return if (type !== "image" && type !== "video") return return { type, src, alt } } function parseHighlight(value: unknown): Highlight | undefined { if (!isRecord(value)) return const title = getText(value.title) if (!title) return const description = getText(value.description) ?? getText(value.shortDescription) if (!description) return const media = parseMedia(value.media, title) return { title, description, media } } function parseRelease(value: unknown): ParsedRelease | undefined { if (!isRecord(value)) return const tag = getText(value.tag) ?? getText(value.tag_name) ?? getText(value.name) if (!Array.isArray(value.highlights)) { return { tag, highlights: [] } } const highlights = value.highlights.flatMap((group) => { if (!isRecord(group)) return [] const source = getText(group.source) if (!source) return [] if (!source.toLowerCase().includes("desktop")) return [] if (Array.isArray(group.items)) { return group.items.map((item) => parseHighlight(item)).filter((item): item is Highlight => item !== undefined) } const item = parseHighlight(group) if (!item) return [] return [item] }) return { tag, highlights } } function parseChangelog(value: unknown): ParsedRelease[] | undefined { if (Array.isArray(value)) { return value.map(parseRelease).filter((release): release is ParsedRelease => release !== undefined) } if (!isRecord(value)) return if (!Array.isArray(value.releases)) return return value.releases.map(parseRelease).filter((release): release is ParsedRelease => release !== undefined) } function sliceHighlights(input: { releases: ParsedRelease[]; current?: string; previous?: string }) { const current = normalizeVersion(input.current) const previous = normalizeVersion(input.previous) const releases = input.releases const start = (() => { if (!current) return 0 const index = releases.findIndex((release) => normalizeVersion(release.tag) === current) return index === -1 ? 0 : index })() const end = (() => { if (!previous) return releases.length const index = releases.findIndex((release, i) => i >= start && normalizeVersion(release.tag) === previous) return index === -1 ? releases.length : index })() const highlights = releases.slice(start, end).flatMap((release) => release.highlights) const seen = new Set() const unique = highlights.filter((highlight) => { const key = [highlight.title, highlight.description, highlight.media?.type ?? "", highlight.media?.src ?? ""].join( "\n", ) if (seen.has(key)) return false seen.add(key) return true }) return unique.slice(0, 5) } export const { use: useHighlights, provider: HighlightsProvider } = createSimpleContext({ name: "Highlights", gate: false, init: () => { const platform = usePlatform() const dialog = useDialog() const settings = useSettings() const [store, setStore, _, ready] = persisted("highlights.v1", createStore({ version: undefined })) const [from, setFrom] = createSignal(undefined) const [to, setTo] = createSignal(undefined) const [timer, setTimer] = createSignal | undefined>(undefined) const state = { started: false } const markSeen = () => { if (!platform.version) return setStore("version", platform.version) } createEffect(() => { if (state.started) return if (!ready()) return if (!settings.ready()) return if (!platform.version) return state.started = true const previous = store.version if (!previous) { setStore("version", platform.version) return } if (previous === platform.version) return setFrom(previous) setTo(platform.version) if (!settings.general.releaseNotes()) { markSeen() return } const fetcher = platform.fetch ?? fetch const controller = new AbortController() onCleanup(() => { controller.abort() const id = timer() if (id === undefined) return clearTimeout(id) }) fetcher(CHANGELOG_URL, { signal: controller.signal, headers: { Accept: "application/json" }, }) .then((response) => (response.ok ? (response.json() as Promise) : undefined)) .then((json) => { if (!json) return const releases = parseChangelog(json) if (!releases) return if (releases.length === 0) return const highlights = sliceHighlights({ releases, current: platform.version, previous, }) if (controller.signal.aborted) return if (highlights.length === 0) { markSeen() return } const timer = setTimeout(() => { markSeen() dialog.show(() => ) }, 500) setTimer(timer) }) .catch(() => undefined) }) return { ready, from, to, get last() { return store.version }, markSeen, } }, })