diff options
| author | adamelmore <[email protected]> | 2026-01-26 07:17:46 -0600 |
|---|---|---|
| committer | adamelmore <[email protected]> | 2026-01-26 15:36:56 -0600 |
| commit | 8b6484ac1a4ff3cfc2c98c38eaeb6e5bf0b4fab4 (patch) | |
| tree | 25f99116f5d56e6c9d512b79253d2cb57ade2dee /packages/app/src/context | |
| parent | c1e840b9b229889c1a85b479b3cd601d6f313c87 (diff) | |
| download | opencode-8b6484ac1a4ff3cfc2c98c38eaeb6e5bf0b4fab4.tar.gz opencode-8b6484ac1a4ff3cfc2c98c38eaeb6e5bf0b4fab4.zip | |
wip: highlights
Diffstat (limited to 'packages/app/src/context')
| -rw-r--r-- | packages/app/src/context/highlights.tsx | 200 |
1 files changed, 200 insertions, 0 deletions
diff --git a/packages/app/src/context/highlights.tsx b/packages/app/src/context/highlights.tsx new file mode 100644 index 000000000..4c2e8c838 --- /dev/null +++ b/packages/app/src/context/highlights.tsx @@ -0,0 +1,200 @@ +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 { 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<string, unknown> { + 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 (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) + if (!src) return + if (type !== "image" && type !== "video") return + + return { type, src, alt } +} + +function parseHighlight(value: unknown, tag: string | undefined): 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, tag, 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 [] + if (!Array.isArray(group.items)) return [] + const source = getText(group.source) + return group.items + .map((item) => parseHighlight(item, source)) + .filter((item): item is Highlight => item !== undefined) + }) + + return { tag, highlights } +} + +function parseChangelog(value: unknown): ParsedRelease[] | 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 + })() + + return releases + .slice(start, end) + .flatMap((release) => release.highlights) + .slice(0, 3) +} + +export const { use: useHighlights, provider: HighlightsProvider } = createSimpleContext({ + name: "Highlights", + gate: false, + init: () => { + const platform = usePlatform() + const dialog = useDialog() + const [store, setStore, _, ready] = persisted("highlights.v1", createStore<Store>({ version: undefined })) + + const [from, setFrom] = createSignal<string | undefined>(undefined) + const [to, setTo] = createSignal<string | undefined>(undefined) + const [timer, setTimer] = createSignal<ReturnType<typeof setTimeout> | 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 (!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) + + 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<unknown>) : undefined)) + .then((json) => { + if (!json) return + const releases = parseChangelog(json) + if (!releases) return + const highlights = sliceHighlights({ + releases, + current: platform.version, + previous, + }) + + if (controller.signal.aborted) return + + if (highlights.length === 0) { + markSeen() + return + } + + const timer = setTimeout(() => { + dialog.show( + () => <DialogReleaseNotes highlights={highlights} />, + () => markSeen(), + ) + }, 500) + setTimer(timer) + }) + .catch(() => undefined) + }) + + return { + ready, + from, + to, + get last() { + return store.version + }, + markSeen, + } + }, +}) |
