highlights.tsx 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. import { createEffect, onCleanup } from "solid-js"
  2. import { createStore } from "solid-js/store"
  3. import { createSimpleContext } from "@opencode-ai/ui/context"
  4. import { useDialog } from "@opencode-ai/ui/context/dialog"
  5. import { usePlatform } from "@/context/platform"
  6. import { useSettings } from "@/context/settings"
  7. import { persisted } from "@/utils/persist"
  8. import { DialogReleaseNotes, type Highlight } from "@/components/dialog-release-notes"
  9. const CHANGELOG_URL = "https://opencode.ai/changelog.json"
  10. type Store = {
  11. version?: string
  12. }
  13. type ParsedRelease = {
  14. tag?: string
  15. highlights: Highlight[]
  16. }
  17. function isRecord(value: unknown): value is Record<string, unknown> {
  18. return typeof value === "object" && value !== null && !Array.isArray(value)
  19. }
  20. function getText(value: unknown): string | undefined {
  21. if (typeof value === "string") {
  22. const text = value.trim()
  23. return text.length > 0 ? text : undefined
  24. }
  25. if (typeof value === "number") return String(value)
  26. return
  27. }
  28. function normalizeVersion(value: string | undefined) {
  29. const text = value?.trim()
  30. if (!text) return
  31. return text.startsWith("v") || text.startsWith("V") ? text.slice(1) : text
  32. }
  33. function parseMedia(value: unknown, alt: string): Highlight["media"] | undefined {
  34. if (!isRecord(value)) return
  35. const type = getText(value.type)?.toLowerCase()
  36. const src = getText(value.src) ?? getText(value.url)
  37. if (!src) return
  38. if (type !== "image" && type !== "video") return
  39. return { type, src, alt }
  40. }
  41. function parseHighlight(value: unknown): Highlight | undefined {
  42. if (!isRecord(value)) return
  43. const title = getText(value.title)
  44. if (!title) return
  45. const description = getText(value.description) ?? getText(value.shortDescription)
  46. if (!description) return
  47. const media = parseMedia(value.media, title)
  48. return { title, description, media }
  49. }
  50. function parseRelease(value: unknown): ParsedRelease | undefined {
  51. if (!isRecord(value)) return
  52. const tag = getText(value.tag) ?? getText(value.tag_name) ?? getText(value.name)
  53. if (!Array.isArray(value.highlights)) {
  54. return { tag, highlights: [] }
  55. }
  56. const highlights = value.highlights.flatMap((group) => {
  57. if (!isRecord(group)) return []
  58. const source = getText(group.source)
  59. if (!source) return []
  60. if (!source.toLowerCase().includes("desktop")) return []
  61. if (Array.isArray(group.items)) {
  62. return group.items.map((item) => parseHighlight(item)).filter((item): item is Highlight => item !== undefined)
  63. }
  64. const item = parseHighlight(group)
  65. if (!item) return []
  66. return [item]
  67. })
  68. return { tag, highlights }
  69. }
  70. function parseChangelog(value: unknown): ParsedRelease[] | undefined {
  71. if (Array.isArray(value)) {
  72. return value.map(parseRelease).filter((release): release is ParsedRelease => release !== undefined)
  73. }
  74. if (!isRecord(value)) return
  75. if (!Array.isArray(value.releases)) return
  76. return value.releases.map(parseRelease).filter((release): release is ParsedRelease => release !== undefined)
  77. }
  78. function sliceHighlights(input: { releases: ParsedRelease[]; current?: string; previous?: string }) {
  79. const current = normalizeVersion(input.current)
  80. const previous = normalizeVersion(input.previous)
  81. const releases = input.releases
  82. const start = (() => {
  83. if (!current) return 0
  84. const index = releases.findIndex((release) => normalizeVersion(release.tag) === current)
  85. return index === -1 ? 0 : index
  86. })()
  87. const end = (() => {
  88. if (!previous) return releases.length
  89. const index = releases.findIndex((release, i) => i >= start && normalizeVersion(release.tag) === previous)
  90. return index === -1 ? releases.length : index
  91. })()
  92. const highlights = releases.slice(start, end).flatMap((release) => release.highlights)
  93. const seen = new Set<string>()
  94. const unique = highlights.filter((highlight) => {
  95. const key = dedupeKey(highlight)
  96. if (seen.has(key)) return false
  97. seen.add(key)
  98. return true
  99. })
  100. return unique.slice(0, 5)
  101. }
  102. function dedupeKey(highlight: Highlight) {
  103. return [highlight.title, highlight.description, highlight.media?.type ?? "", highlight.media?.src ?? ""].join("\n")
  104. }
  105. function loadReleaseHighlights(value: unknown, current?: string, previous?: string) {
  106. const releases = parseChangelog(value)
  107. if (!releases?.length) return []
  108. return sliceHighlights({ releases, current, previous })
  109. }
  110. export const { use: useHighlights, provider: HighlightsProvider } = createSimpleContext({
  111. name: "Highlights",
  112. gate: false,
  113. init: () => {
  114. const platform = usePlatform()
  115. const dialog = useDialog()
  116. const settings = useSettings()
  117. const [store, setStore, _, ready] = persisted("highlights.v1", createStore<Store>({ version: undefined }))
  118. const [range, setRange] = createStore({
  119. from: undefined as string | undefined,
  120. to: undefined as string | undefined,
  121. })
  122. const state = { started: false }
  123. let timer: ReturnType<typeof setTimeout> | undefined
  124. const clearTimer = () => {
  125. if (timer === undefined) return
  126. clearTimeout(timer)
  127. timer = undefined
  128. }
  129. const markSeen = () => {
  130. if (!platform.version) return
  131. setStore("version", platform.version)
  132. }
  133. const start = (previous: string) => {
  134. if (!settings.general.releaseNotes()) {
  135. markSeen()
  136. return
  137. }
  138. const fetcher = platform.fetch ?? fetch
  139. const controller = new AbortController()
  140. onCleanup(() => {
  141. controller.abort()
  142. clearTimer()
  143. })
  144. fetcher(CHANGELOG_URL, {
  145. signal: controller.signal,
  146. headers: { Accept: "application/json" },
  147. })
  148. .then((response) => (response.ok ? (response.json() as Promise<unknown>) : undefined))
  149. .then((json) => {
  150. if (!json) return
  151. const highlights = loadReleaseHighlights(json, platform.version, previous)
  152. if (controller.signal.aborted) return
  153. if (highlights.length === 0) {
  154. markSeen()
  155. return
  156. }
  157. timer = setTimeout(() => {
  158. timer = undefined
  159. markSeen()
  160. dialog.show(() => <DialogReleaseNotes highlights={highlights} />)
  161. }, 500)
  162. })
  163. .catch(() => undefined)
  164. }
  165. createEffect(() => {
  166. if (state.started) return
  167. if (!ready()) return
  168. if (!settings.ready()) return
  169. if (!platform.version) return
  170. state.started = true
  171. const previous = store.version
  172. if (!previous) {
  173. setStore("version", platform.version)
  174. return
  175. }
  176. if (previous === platform.version) return
  177. setRange({ from: previous, to: platform.version })
  178. start(previous)
  179. })
  180. return {
  181. ready,
  182. from: () => range.from,
  183. to: () => range.to,
  184. get last() {
  185. return store.version
  186. },
  187. markSeen,
  188. }
  189. },
  190. })