diff --git a/packages/app/src/components/dialog-release-notes.tsx b/packages/app/src/components/dialog-release-notes.tsx index c62cbc1883..d3ee7e201a 100644 --- a/packages/app/src/components/dialog-release-notes.tsx +++ b/packages/app/src/components/dialog-release-notes.tsx @@ -2,11 +2,11 @@ import { createSignal, createEffect, onMount, onCleanup } from "solid-js" import { Dialog } from "@opencode-ai/ui/dialog" import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useSettings } from "@/context/settings" export type Highlight = { title: string description: string - tag?: string media?: { type: "image" | "video" src: string @@ -16,6 +16,7 @@ export type Highlight = { export function DialogReleaseNotes(props: { highlights: Highlight[] }) { const dialog = useDialog() + const settings = useSettings() const [index, setIndex] = createSignal(0) const total = () => props.highlights.length @@ -34,9 +35,20 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) { dialog.close() } + function handleDisable() { + settings.general.setReleaseNotes(false) + handleClose() + } + let focusTrap: HTMLDivElement | undefined function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") { + e.preventDefault() + handleClose() + return + } + if (!paged()) return if (e.key === "ArrowLeft" && !isFirst()) { e.preventDefault() @@ -50,8 +62,6 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) { onMount(() => { focusTrap?.focus() - - if (!paged()) return document.addEventListener("keydown", handleKeyDown) onCleanup(() => document.removeEventListener("keydown", handleKeyDown)) }) @@ -72,14 +82,6 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {

{feature()?.title ?? ""}

- {feature()?.tag && ( - - {feature()!.tag} - - )}

{feature()?.description ?? ""}

@@ -89,7 +91,7 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) { {/* Bottom section - buttons and indicators (fixed position) */}
-
+
{isLast() ? ( )} + +
{paged() && ( diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index cfb8a998d3..e7e5b67f3a 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -214,6 +214,23 @@ export const SettingsGeneral: Component = () => {
+ {/* Updates Section */} +
+

{language.t("settings.general.section.updates")}

+ +
+ + settings.general.setReleaseNotes(checked)} + /> + +
+
+ {/* Sound effects Section */}

{language.t("settings.general.section.sounds")}

diff --git a/packages/app/src/context/highlights.tsx b/packages/app/src/context/highlights.tsx index 2d20660d72..e55bca675d 100644 --- a/packages/app/src/context/highlights.tsx +++ b/packages/app/src/context/highlights.tsx @@ -3,10 +3,11 @@ 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://dev.opencode.ai/changelog.json" +const CHANGELOG_URL = "https://opencode.ai/changelog.json" type Store = { version?: string @@ -18,7 +19,7 @@ type ParsedRelease = { } function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null + return typeof value === "object" && value !== null && !Array.isArray(value) } function getText(value: unknown): string | undefined { @@ -40,14 +41,14 @@ function normalizeVersion(value: string | undefined) { function parseMedia(value: unknown, alt: string): Highlight["media"] | undefined { if (!isRecord(value)) return const type = getText(value.type)?.toLowerCase() - const src = getText(value.src) + 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, tag: string | undefined): Highlight | undefined { +function parseHighlight(value: unknown): Highlight | undefined { if (!isRecord(value)) return const title = getText(value.title) @@ -57,7 +58,7 @@ function parseHighlight(value: unknown, tag: string | undefined): Highlight | un if (!description) return const media = parseMedia(value.media, title) - return { title, description, tag, media } + return { title, description, media } } function parseRelease(value: unknown): ParsedRelease | undefined { @@ -70,11 +71,18 @@ function parseRelease(value: unknown): ParsedRelease | undefined { 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) + 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 } @@ -108,10 +116,17 @@ function sliceHighlights(input: { releases: ParsedRelease[]; current?: string; p return index === -1 ? releases.length : index })() - return releases - .slice(start, end) - .flatMap((release) => release.highlights) - .slice(0, 3) + 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, 3) } export const { use: useHighlights, provider: HighlightsProvider } = createSimpleContext({ @@ -120,6 +135,7 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple 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) @@ -135,6 +151,7 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple createEffect(() => { if (state.started) return if (!ready()) return + if (!settings.ready()) return if (!platform.version) return state.started = true @@ -149,6 +166,11 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple setFrom(previous) setTo(platform.version) + if (!settings.general.releaseNotes()) { + markSeen() + return + } + const fetcher = platform.fetch ?? fetch const controller = new AbortController() onCleanup(() => { @@ -182,10 +204,8 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple } const timer = setTimeout(() => { - dialog.show( - () => , - () => markSeen(), - ) + markSeen() + dialog.show(() => ) }, 500) setTimer(timer) }) diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index d976cbc496..67e907a636 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -18,6 +18,7 @@ export interface SoundSettings { export interface Settings { general: { autoSave: boolean + releaseNotes: boolean } appearance: { fontSize: number @@ -34,6 +35,7 @@ export interface Settings { const defaultSettings: Settings = { general: { autoSave: true, + releaseNotes: true, }, appearance: { fontSize: 14, @@ -97,6 +99,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setAutoSave(value: boolean) { setStore("general", "autoSave", value) }, + releaseNotes: createMemo(() => store.general?.releaseNotes ?? defaultSettings.general.releaseNotes), + setReleaseNotes(value: boolean) { + setStore("general", "releaseNotes", value) + }, }, appearance: { fontSize: createMemo(() => store.appearance?.fontSize ?? defaultSettings.appearance.fontSize), diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index d368fff334..bca08275f7 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -525,6 +525,7 @@ export const dict = { "settings.general.section.appearance": "Appearance", "settings.general.section.notifications": "System notifications", + "settings.general.section.updates": "Updates", "settings.general.section.sounds": "Sound effects", "settings.general.row.language.title": "Language", @@ -535,6 +536,9 @@ export const dict = { "settings.general.row.theme.description": "Customise how OpenCode is themed.", "settings.general.row.font.title": "Font", "settings.general.row.font.description": "Customise the mono font used in code blocks", + + "settings.general.row.releaseNotes.title": "Release notes", + "settings.general.row.releaseNotes.description": "Show What's New popups after updates", "font.option.ibmPlexMono": "IBM Plex Mono", "font.option.cascadiaCode": "Cascadia Code", "font.option.firaCode": "Fira Code",