1import type { EnvironmentId, PullRequestContextMetadata } from "@t3tools/contracts";
2import { CircleDashedIcon, FilmIcon, ImageIcon } from "lucide-react";
3import {
4 useState,
5 type ComponentProps,
6 type CSSProperties,
7 type MouseEvent,
8 type ReactNode,
9} from "react";
10
11import { PULL_REQUEST_STATE_PRESENTATION } from "~/components/pullRequest/pullRequestIcons";
12import type { PullRequestContextDisplayState } from "~/lib/composerContextRecords";
13import { PierreEntryIcon } from "./chat/PierreEntryIcon";
14import { middleTruncateAttachmentName } from "./composerInlineChip";
15import { PullRequestContextDetails } from "./PullRequestContextDetails";
16import { ContextChip, ContextChipLabel, type ContextChipKind } from "./ContextChip";
17import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "./ui/popover";
18import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
19import { PullRequestLinkPreview } from "./pullRequest/PullRequestLinkPreview";
20import { usePullRequestPreviewTarget } from "~/lib/openPullRequestLink";
21
22/** ContextChip kind for a pull request context in each display state. */
23export const PULL_REQUEST_CHIP_KINDS = {
24 open: "pr-open",
25 draft: "pr-draft",
26 merged: "pr-merged",
27 closed: "pr-closed",
28 unknown: "pull-request",
29} as const satisfies Record<PullRequestContextDisplayState | "unknown", ContextChipKind>;
30
31/** A static chip with an optional tooltip; each surface keeps ownership of payload lookup. */
32export function ContextChipShell({
33 icon,
34 label,
35 tooltip,
36 ...props
37}: Omit<ComponentProps<typeof ContextChip>, "className" | "render"> & {
38 icon: ReactNode;
39 label: string;
40 /** Newlines in the tooltip are kept. */
41 tooltip?: ReactNode;
42}) {
43 const chip = (
44 <ContextChip
45 data-context-unresolved={props.state === "unresolved" ? "true" : undefined}
46 tabIndex={tooltip ? 0 : undefined}
47 {...props}
48 >
49 {icon}
50 <ContextChipLabel>{label}</ContextChipLabel>
51 </ContextChip>
52 );
53 if (!tooltip) return chip;
54 return (
55 <Tooltip>
56 <TooltipTrigger render={chip} />
57 <TooltipPopup side="top" className="whitespace-pre-wrap">
58 {tooltip}
59 </TooltipPopup>
60 </Tooltip>
61 );
62}
63
64/** A chip that opens its details in a popover; the chip itself is the trigger. */
65export function ContextChipPopover(props: {
66 kind: ContextChipKind;
67 icon: ReactNode;
68 label: string;
69 copyMarkdown?: string;
70 accessibleLabel: string;
71 children: ReactNode;
72}) {
73 return (
74 <Popover>
75 <PopoverTrigger
76 render={
77 <ContextChip
78 kind={props.kind}
79 render={<button type="button" />}
80 aria-label={`${props.accessibleLabel}. Show details`}
81 data-markdown-copy={props.copyMarkdown}
82 />
83 }
84 >
85 {props.icon}
86 <ContextChipLabel>{props.label}</ContextChipLabel>
87 </PopoverTrigger>
88 <PopoverPopup side="top" width="lg" padding="compact">
89 <PopoverTitle className="sr-only">{props.accessibleLabel}</PopoverTitle>
90 {props.children}
91 </PopoverPopup>
92 </Popover>
93 );
94}
95
96export function PullRequestChip(props: {
97 metadata: PullRequestContextMetadata;
98 environmentId: EnvironmentId | null;
99 label: string;
100 kindLabel: string;
101 kind: ContextChipKind;
102 copyMarkdown?: string;
103 onOpen: (event: MouseEvent<HTMLElement>, url: string) => void;
104}) {
105 const previewTarget = usePullRequestPreviewTarget(props.environmentId, props.metadata.url);
106 const displayState =
107 props.metadata.state === "open" && props.metadata.isDraft ? "draft" : props.metadata.state;
108 const StateIcon = PULL_REQUEST_STATE_PRESENTATION[displayState].Icon;
109 const button = (
110 <ContextChip
111 kind={props.kind}
112 render={<button type="button" />}
113 aria-label={`Open ${props.kindLabel} ${props.label}: ${props.metadata.title}`}
114 data-markdown-copy={props.copyMarkdown}
115 onClick={(event) => props.onOpen(event, props.metadata.url)}
116 >
117 <StateIcon />
118 <ContextChipLabel>{props.label}</ContextChipLabel>
119 </ContextChip>
120 );
121 if (previewTarget !== null) {
122 return (
123 <PullRequestLinkPreview
124 link={button}
125 originalUrl={props.metadata.url}
126 target={previewTarget}
127 confirmBeforeOpen={false}
128 fallback={<PullRequestContextDetails metadata={props.metadata} />}
129 />
130 );
131 }
132 return (
133 <Tooltip>
134 <TooltipTrigger render={button} />
135 <TooltipPopup side="top">
136 <PullRequestContextDetails metadata={props.metadata} />
137 </TooltipPopup>
138 </Tooltip>
139 );
140}
141
142/** Sample the loaded thumbnail once; transparent pixels should not darken its accent. */
143function averageImageColor(image: HTMLImageElement): string | undefined {
144 try {
145 const canvas = document.createElement("canvas");
146 canvas.width = canvas.height = 16;
147 const context = canvas.getContext("2d");
148 if (!context) return;
149 context.drawImage(image, 0, 0, 16, 16);
150 const { data } = context.getImageData(0, 0, 16, 16);
151 let red = 0;
152 let green = 0;
153 let blue = 0;
154 let alpha = 0;
155 for (let index = 0; index < data.length; index += 4) {
156 const weight = data[index + 3]!;
157 red += data[index]! * weight;
158 green += data[index + 1]! * weight;
159 blue += data[index + 2]! * weight;
160 alpha += weight;
161 }
162 if (alpha === 0) return;
163 return `rgb(${Math.round(red / alpha)} ${Math.round(green / alpha)} ${Math.round(blue / alpha)})`;
164 } catch {
165 // Cross-origin or unavailable pixels keep the default image tone and preview action.
166 return;
167 }
168}
169
170export function ImageChipButton({
171 name,
172 previewUrl,
173 size,
174 suffix,
175 style,
176 ...props
177}: Omit<ComponentProps<typeof ContextChip>, "kind" | "render"> & {
178 name: string;
179 previewUrl: string | undefined;
180 /** Every attachment chip reports its size; images are no exception. */
181 size: string;
182 suffix?: string | null;
183}) {
184 const [sample, setSample] = useState<{ url: string; color: string | undefined }>();
185 const [corsFailedUrl, setCorsFailedUrl] = useState<string>();
186 const accent = sample?.url === previewUrl ? sample?.color : undefined;
187 return (
188 <ContextChip
189 kind="image"
190 render={<button type="button" />}
191 aria-label={`Image attachment, ${name}, ${size}`}
192 style={{ ...style, ...(accent ? { "--context-chip-accent": accent } : {}) } as CSSProperties}
193 {...props}
194 >
195 {previewUrl ? (
196 <img
197 key={previewUrl}
198 crossOrigin={corsFailedUrl === previewUrl ? undefined : "anonymous"}
199 src={previewUrl}
200 alt=""
201 className="size-[1.17em] shrink-0 rounded-sm object-cover"
202 onError={() => setCorsFailedUrl(previewUrl)}
203 onLoad={(event) =>
204 setSample({ url: previewUrl, color: averageImageColor(event.currentTarget) })
205 }
206 />
207 ) : (
208 <ImageIcon />
209 )}
210 <ContextChipLabel className="max-w-72">{middleTruncateAttachmentName(name)}</ContextChipLabel>
211 <span className="shrink-0 text-3xs text-current">{size}</span>
212 {suffix ? <span className="text-3xs text-current">{suffix}</span> : null}
213 </ContextChip>
214 );
215}
216
217export function FileChip(props: {
218 name: string;
219 size: string;
220 isVideo: boolean;
221 theme: "light" | "dark";
222 accessibleLabel: string;
223 tooltip: string;
224 suffix?: string | null;
225 copyMarkdown?: string;
226 disabled?: boolean;
227 error?: boolean;
228 unresolved?: boolean;
229 onOpen?: (() => void) | undefined;
230}) {
231 const content = <FileChipContent {...props} />;
232 const state = props.unresolved
233 ? ("unresolved" as const)
234 : props.error
235 ? ("invalid" as const)
236 : null;
237 const attributes = {
238 kind: props.isVideo ? "video" : "file",
239 ...(state ? { state } : {}),
240 "aria-label": [props.accessibleLabel, props.suffix].filter(Boolean).join(", "),
241 "data-markdown-copy": props.copyMarkdown,
242 "data-context-unresolved": props.unresolved ? "true" : undefined,
243 } as const;
244 return (
245 <Tooltip>
246 <TooltipTrigger
247 render={
248 props.onOpen ? (
249 <ContextChip
250 render={<button type="button" disabled={props.disabled} />}
251 onClick={props.onOpen}
252 {...attributes}
253 >
254 {content}
255 </ContextChip>
256 ) : (
257 <ContextChip tabIndex={0} {...attributes}>
258 {content}
259 </ContextChip>
260 )
261 }
262 />
263 <TooltipPopup side="top" className="whitespace-pre-wrap">
264 {props.tooltip}
265 </TooltipPopup>
266 </Tooltip>
267 );
268}
269
270function FileChipContent(props: {
271 name: string;
272 size: string;
273 isVideo: boolean;
274 theme: "light" | "dark";
275 suffix?: string | null;
276}) {
277 return (
278 <>
279 {props.isVideo ? (
280 <FilmIcon />
281 ) : (
282 <PierreEntryIcon pathValue={props.name} kind="file" theme={props.theme} />
283 )}
284 <ContextChipLabel className="max-w-72">
285 {middleTruncateAttachmentName(props.name)}
286 </ContextChipLabel>
287 <span className="shrink-0 text-3xs text-current">{props.size}</span>
288 {props.suffix ? <span className="text-3xs text-current">{props.suffix}</span> : null}
289 </>
290 );
291}
292
293export function UnresolvedChip(props: { label: string; tooltip: string; copyMarkdown?: string }) {
294 return (
295 <ContextChipShell
296 icon={<CircleDashedIcon />}
297 label={props.label}
298 state="unresolved"
299 aria-label={`Unavailable context, ${props.label}`}
300 data-markdown-copy={props.copyMarkdown}
301 tooltip={props.tooltip}
302 />
303 );
304}