| 1 | import type { EnvironmentId, PullRequestContextMetadata } from "@t3tools/contracts"; |
| 2 | import { CircleDashedIcon, FilmIcon, ImageIcon } from "lucide-react"; |
| 3 | import { |
| 4 | useState, |
| 5 | type ComponentProps, |
| 6 | type CSSProperties, |
| 7 | type MouseEvent, |
| 8 | type ReactNode, |
| 9 | } from "react"; |
| 10 | |
| 11 | import { PULL_REQUEST_STATE_PRESENTATION } from "~/components/pullRequest/pullRequestIcons"; |
| 12 | import type { PullRequestContextDisplayState } from "~/lib/composerContextRecords"; |
| 13 | import { PierreEntryIcon } from "./chat/PierreEntryIcon"; |
| 14 | import { middleTruncateAttachmentName } from "./composerInlineChip"; |
| 15 | import { PullRequestContextDetails } from "./PullRequestContextDetails"; |
| 16 | import { ContextChip, ContextChipLabel, type ContextChipKind } from "./ContextChip"; |
| 17 | import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "./ui/popover"; |
| 18 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 19 | import { PullRequestLinkPreview } from "./pullRequest/PullRequestLinkPreview"; |
| 20 | import { usePullRequestPreviewTarget } from "~/lib/openPullRequestLink"; |
| 21 | |
| 22 | /** ContextChip kind for a pull request context in each display state. */ |
| 23 | export 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. */ |
| 32 | export 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. */ |
| 65 | export 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 | |
| 96 | export 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. */ |
| 143 | function 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 | |
| 170 | export 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 | |
| 217 | export 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 | |
| 270 | function 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 | |
| 293 | export 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 | } |