1import type { DesktopBridge, DesktopUpdateState } from "@t3tools/contracts";
2import { ExternalLinkIcon } from "lucide-react";
3
4import {
5 getDesktopUpdateReleaseHistoryUrl,
6 getDesktopUpdateReleaseUrl,
7} from "../desktopUpdate.logic";
8import { openDesktopUpdateReleaseNotes } from "../desktopUpdate.toast";
9import { Separator } from "../ui/separator";
10
11type DesktopUpdateShell = Pick<DesktopBridge, "openExternal">;
12
13function keyReleaseNoteItems(items: ReadonlyArray<string>) {
14 const occurrences = new Map<string, number>();
15 return items.map((item) => {
16 const occurrence = occurrences.get(item) ?? 0;
17 occurrences.set(item, occurrence + 1);
18 return { item, key: JSON.stringify([item, occurrence]) };
19 });
20}
21
22function ReleaseLink({
23 children,
24 releaseUrl,
25 shell,
26}: {
27 readonly children: string;
28 readonly releaseUrl: string;
29 readonly shell: DesktopUpdateShell | undefined;
30}) {
31 return (
32 <a
33 className="mt-2 inline-flex items-center gap-1 rounded-sm text-xs leading-5 text-muted-foreground underline decoration-dotted underline-offset-4 outline-none transition-colors hover:text-foreground focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
34 href={releaseUrl}
35 onClick={(event) => {
36 event.preventDefault();
37 void openDesktopUpdateReleaseNotes(shell, releaseUrl);
38 }}
39 >
40 {children}
41 <ExternalLinkIcon aria-hidden className="size-3 shrink-0" strokeWidth={2.25} />
42 </a>
43 );
44}
45
46export function SidebarUpdateReleaseNotes({
47 shell,
48 state,
49 tooltip,
50}: {
51 readonly shell: DesktopUpdateShell | undefined;
52 readonly state: DesktopUpdateState;
53 readonly tooltip: string;
54}) {
55 if (state.channel !== "nightly" || state.releaseNotes.length === 0) {
56 return <>{tooltip}</>;
57 }
58
59 return (
60 <div className="flex max-h-[calc(var(--available-height)-0.5rem)] min-h-0 w-fit max-w-[min(24rem,calc(100vw-2rem))] flex-col text-left">
61 <div className="shrink-0 px-1">
62 {state.status === "available" ? (
63 <div>
64 <div className="whitespace-nowrap text-sm leading-5 font-medium">
65 Update ready to download
66 </div>
67 {state.availableVersion ? (
68 <div className="mt-0.5 text-xs leading-4 text-muted-foreground">
69 {state.availableVersion}
70 </div>
71 ) : null}
72 </div>
73 ) : (
74 <div className="text-sm leading-5 font-medium">{tooltip}</div>
75 )}
76 </div>
77 <div className="min-h-0 max-h-[min(28rem,calc(100vh-6rem))] overflow-y-auto px-1 pt-4 pb-1">
78 {state.releaseNotes.map((releaseNote, index) => {
79 const releaseUrl = getDesktopUpdateReleaseUrl(releaseNote.version);
80 const omittedItemCount = Math.max(0, releaseNote.totalItems - releaseNote.items.length);
81 const linkLabel =
82 omittedItemCount === 0
83 ? "View release on GitHub"
84 : `${omittedItemCount} more ${omittedItemCount === 1 ? "change" : "changes"} on GitHub`;
85
86 return (
87 <div key={releaseNote.version}>
88 {index > 0 && <Separator className="my-3" />}
89 <section>
90 <h3 className="text-foreground text-xs leading-4 font-semibold">
91 {index === 0 ? "What's changed" : `Changes in ${releaseNote.version}`}
92 </h3>
93 <ul className="mt-2 space-y-1.5 pl-4 text-xs leading-5 text-popover-foreground/90">
94 {keyReleaseNoteItems(releaseNote.items).map(({ item, key }) => (
95 <li className="list-disc break-words" key={key}>
96 {item}
97 </li>
98 ))}
99 </ul>
100 {releaseUrl ? (
101 <ReleaseLink releaseUrl={releaseUrl} shell={shell}>
102 {linkLabel}
103 </ReleaseLink>
104 ) : null}
105 </section>
106 </div>
107 );
108 })}
109 {state.omittedReleaseCount > 0 ? (
110 <div>
111 <Separator className="my-3" />
112 <ReleaseLink releaseUrl={getDesktopUpdateReleaseHistoryUrl()} shell={shell}>
113 {`${state.omittedReleaseCount} older ${state.omittedReleaseCount === 1 ? "release" : "releases"} on GitHub`}
114 </ReleaseLink>
115 </div>
116 ) : null}
117 </div>
118 </div>
119 );
120}