wip: highlights
This commit is contained in:
@@ -2,11 +2,11 @@ import { createSignal, createEffect, onMount, onCleanup } from "solid-js"
|
|||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { useSettings } from "@/context/settings"
|
||||||
|
|
||||||
export type Highlight = {
|
export type Highlight = {
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
tag?: string
|
|
||||||
media?: {
|
media?: {
|
||||||
type: "image" | "video"
|
type: "image" | "video"
|
||||||
src: string
|
src: string
|
||||||
@@ -16,6 +16,7 @@ export type Highlight = {
|
|||||||
|
|
||||||
export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
|
export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
const settings = useSettings()
|
||||||
const [index, setIndex] = createSignal(0)
|
const [index, setIndex] = createSignal(0)
|
||||||
|
|
||||||
const total = () => props.highlights.length
|
const total = () => props.highlights.length
|
||||||
@@ -34,9 +35,20 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
|
|||||||
dialog.close()
|
dialog.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleDisable() {
|
||||||
|
settings.general.setReleaseNotes(false)
|
||||||
|
handleClose()
|
||||||
|
}
|
||||||
|
|
||||||
let focusTrap: HTMLDivElement | undefined
|
let focusTrap: HTMLDivElement | undefined
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault()
|
||||||
|
handleClose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!paged()) return
|
if (!paged()) return
|
||||||
if (e.key === "ArrowLeft" && !isFirst()) {
|
if (e.key === "ArrowLeft" && !isFirst()) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -50,8 +62,6 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
focusTrap?.focus()
|
focusTrap?.focus()
|
||||||
|
|
||||||
if (!paged()) return
|
|
||||||
document.addEventListener("keydown", handleKeyDown)
|
document.addEventListener("keydown", handleKeyDown)
|
||||||
onCleanup(() => document.removeEventListener("keydown", handleKeyDown))
|
onCleanup(() => document.removeEventListener("keydown", handleKeyDown))
|
||||||
})
|
})
|
||||||
@@ -72,14 +82,6 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
|
|||||||
<div class="flex flex-col gap-2 pt-22">
|
<div class="flex flex-col gap-2 pt-22">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<h1 class="text-16-medium text-text-strong">{feature()?.title ?? ""}</h1>
|
<h1 class="text-16-medium text-text-strong">{feature()?.title ?? ""}</h1>
|
||||||
{feature()?.tag && (
|
|
||||||
<span
|
|
||||||
class="text-12-medium text-text-weak px-1.5 py-0.5 bg-surface-base rounded-sm border border-border-weak-base"
|
|
||||||
style={{ "border-width": "0.5px" }}
|
|
||||||
>
|
|
||||||
{feature()!.tag}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<p class="text-14-regular text-text-base">{feature()?.description ?? ""}</p>
|
<p class="text-14-regular text-text-base">{feature()?.description ?? ""}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -89,7 +91,7 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
|
|||||||
|
|
||||||
{/* Bottom section - buttons and indicators (fixed position) */}
|
{/* Bottom section - buttons and indicators (fixed position) */}
|
||||||
<div class="flex flex-col gap-12">
|
<div class="flex flex-col gap-12">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex flex-col items-start gap-3">
|
||||||
{isLast() ? (
|
{isLast() ? (
|
||||||
<Button variant="primary" size="large" onClick={handleClose}>
|
<Button variant="primary" size="large" onClick={handleClose}>
|
||||||
Get started
|
Get started
|
||||||
@@ -99,6 +101,10 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
|
|||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Button variant="ghost" size="small" onClick={handleDisable}>
|
||||||
|
Don't show these in the future
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{paged() && (
|
{paged() && (
|
||||||
|
|||||||
@@ -214,6 +214,23 @@ export const SettingsGeneral: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Updates Section */}
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>
|
||||||
|
|
||||||
|
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.releaseNotes.title")}
|
||||||
|
description={language.t("settings.general.row.releaseNotes.description")}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
checked={settings.general.releaseNotes()}
|
||||||
|
onChange={(checked) => settings.general.setReleaseNotes(checked)}
|
||||||
|
/>
|
||||||
|
</SettingsRow>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Sound effects Section */}
|
{/* Sound effects Section */}
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.sounds")}</h3>
|
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.sounds")}</h3>
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import { createStore } from "solid-js/store"
|
|||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { useSettings } from "@/context/settings"
|
||||||
import { persisted } from "@/utils/persist"
|
import { persisted } from "@/utils/persist"
|
||||||
import { DialogReleaseNotes, type Highlight } from "@/components/dialog-release-notes"
|
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 = {
|
type Store = {
|
||||||
version?: string
|
version?: string
|
||||||
@@ -18,7 +19,7 @@ type ParsedRelease = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === "object" && value !== null
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getText(value: unknown): string | undefined {
|
function getText(value: unknown): string | undefined {
|
||||||
@@ -40,14 +41,14 @@ function normalizeVersion(value: string | undefined) {
|
|||||||
function parseMedia(value: unknown, alt: string): Highlight["media"] | undefined {
|
function parseMedia(value: unknown, alt: string): Highlight["media"] | undefined {
|
||||||
if (!isRecord(value)) return
|
if (!isRecord(value)) return
|
||||||
const type = getText(value.type)?.toLowerCase()
|
const type = getText(value.type)?.toLowerCase()
|
||||||
const src = getText(value.src)
|
const src = getText(value.src) ?? getText(value.url)
|
||||||
if (!src) return
|
if (!src) return
|
||||||
if (type !== "image" && type !== "video") return
|
if (type !== "image" && type !== "video") return
|
||||||
|
|
||||||
return { type, src, alt }
|
return { type, src, alt }
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseHighlight(value: unknown, tag: string | undefined): Highlight | undefined {
|
function parseHighlight(value: unknown): Highlight | undefined {
|
||||||
if (!isRecord(value)) return
|
if (!isRecord(value)) return
|
||||||
|
|
||||||
const title = getText(value.title)
|
const title = getText(value.title)
|
||||||
@@ -57,7 +58,7 @@ function parseHighlight(value: unknown, tag: string | undefined): Highlight | un
|
|||||||
if (!description) return
|
if (!description) return
|
||||||
|
|
||||||
const media = parseMedia(value.media, title)
|
const media = parseMedia(value.media, title)
|
||||||
return { title, description, tag, media }
|
return { title, description, media }
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseRelease(value: unknown): ParsedRelease | undefined {
|
function parseRelease(value: unknown): ParsedRelease | undefined {
|
||||||
@@ -70,11 +71,18 @@ function parseRelease(value: unknown): ParsedRelease | undefined {
|
|||||||
|
|
||||||
const highlights = value.highlights.flatMap((group) => {
|
const highlights = value.highlights.flatMap((group) => {
|
||||||
if (!isRecord(group)) return []
|
if (!isRecord(group)) return []
|
||||||
if (!Array.isArray(group.items)) return []
|
|
||||||
const source = getText(group.source)
|
const source = getText(group.source)
|
||||||
return group.items
|
if (!source) return []
|
||||||
.map((item) => parseHighlight(item, source))
|
if (!source.toLowerCase().includes("desktop")) return []
|
||||||
.filter((item): item is Highlight => item !== undefined)
|
|
||||||
|
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 }
|
return { tag, highlights }
|
||||||
@@ -108,10 +116,17 @@ function sliceHighlights(input: { releases: ParsedRelease[]; current?: string; p
|
|||||||
return index === -1 ? releases.length : index
|
return index === -1 ? releases.length : index
|
||||||
})()
|
})()
|
||||||
|
|
||||||
return releases
|
const highlights = releases.slice(start, end).flatMap((release) => release.highlights)
|
||||||
.slice(start, end)
|
const seen = new Set<string>()
|
||||||
.flatMap((release) => release.highlights)
|
const unique = highlights.filter((highlight) => {
|
||||||
.slice(0, 3)
|
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({
|
export const { use: useHighlights, provider: HighlightsProvider } = createSimpleContext({
|
||||||
@@ -120,6 +135,7 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
|
|||||||
init: () => {
|
init: () => {
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
const settings = useSettings()
|
||||||
const [store, setStore, _, ready] = persisted("highlights.v1", createStore<Store>({ version: undefined }))
|
const [store, setStore, _, ready] = persisted("highlights.v1", createStore<Store>({ version: undefined }))
|
||||||
|
|
||||||
const [from, setFrom] = createSignal<string | undefined>(undefined)
|
const [from, setFrom] = createSignal<string | undefined>(undefined)
|
||||||
@@ -135,6 +151,7 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
|
|||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (state.started) return
|
if (state.started) return
|
||||||
if (!ready()) return
|
if (!ready()) return
|
||||||
|
if (!settings.ready()) return
|
||||||
if (!platform.version) return
|
if (!platform.version) return
|
||||||
state.started = true
|
state.started = true
|
||||||
|
|
||||||
@@ -149,6 +166,11 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
|
|||||||
setFrom(previous)
|
setFrom(previous)
|
||||||
setTo(platform.version)
|
setTo(platform.version)
|
||||||
|
|
||||||
|
if (!settings.general.releaseNotes()) {
|
||||||
|
markSeen()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const fetcher = platform.fetch ?? fetch
|
const fetcher = platform.fetch ?? fetch
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
@@ -182,10 +204,8 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
|
|||||||
}
|
}
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
dialog.show(
|
markSeen()
|
||||||
() => <DialogReleaseNotes highlights={highlights} />,
|
dialog.show(() => <DialogReleaseNotes highlights={highlights} />)
|
||||||
() => markSeen(),
|
|
||||||
)
|
|
||||||
}, 500)
|
}, 500)
|
||||||
setTimer(timer)
|
setTimer(timer)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface SoundSettings {
|
|||||||
export interface Settings {
|
export interface Settings {
|
||||||
general: {
|
general: {
|
||||||
autoSave: boolean
|
autoSave: boolean
|
||||||
|
releaseNotes: boolean
|
||||||
}
|
}
|
||||||
appearance: {
|
appearance: {
|
||||||
fontSize: number
|
fontSize: number
|
||||||
@@ -34,6 +35,7 @@ export interface Settings {
|
|||||||
const defaultSettings: Settings = {
|
const defaultSettings: Settings = {
|
||||||
general: {
|
general: {
|
||||||
autoSave: true,
|
autoSave: true,
|
||||||
|
releaseNotes: true,
|
||||||
},
|
},
|
||||||
appearance: {
|
appearance: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
@@ -97,6 +99,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
setAutoSave(value: boolean) {
|
setAutoSave(value: boolean) {
|
||||||
setStore("general", "autoSave", value)
|
setStore("general", "autoSave", value)
|
||||||
},
|
},
|
||||||
|
releaseNotes: createMemo(() => store.general?.releaseNotes ?? defaultSettings.general.releaseNotes),
|
||||||
|
setReleaseNotes(value: boolean) {
|
||||||
|
setStore("general", "releaseNotes", value)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
appearance: {
|
appearance: {
|
||||||
fontSize: createMemo(() => store.appearance?.fontSize ?? defaultSettings.appearance.fontSize),
|
fontSize: createMemo(() => store.appearance?.fontSize ?? defaultSettings.appearance.fontSize),
|
||||||
|
|||||||
@@ -525,6 +525,7 @@ export const dict = {
|
|||||||
|
|
||||||
"settings.general.section.appearance": "Appearance",
|
"settings.general.section.appearance": "Appearance",
|
||||||
"settings.general.section.notifications": "System notifications",
|
"settings.general.section.notifications": "System notifications",
|
||||||
|
"settings.general.section.updates": "Updates",
|
||||||
"settings.general.section.sounds": "Sound effects",
|
"settings.general.section.sounds": "Sound effects",
|
||||||
|
|
||||||
"settings.general.row.language.title": "Language",
|
"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.theme.description": "Customise how OpenCode is themed.",
|
||||||
"settings.general.row.font.title": "Font",
|
"settings.general.row.font.title": "Font",
|
||||||
"settings.general.row.font.description": "Customise the mono font used in code blocks",
|
"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.ibmPlexMono": "IBM Plex Mono",
|
||||||
"font.option.cascadiaCode": "Cascadia Code",
|
"font.option.cascadiaCode": "Cascadia Code",
|
||||||
"font.option.firaCode": "Fira Code",
|
"font.option.firaCode": "Fira Code",
|
||||||
|
|||||||
Reference in New Issue
Block a user