Open History to see the last commit
1import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";
2import { imageMimeType } from "@t3tools/shared/image";
3import { videoMimeType } from "@t3tools/shared/video";
4
5import type { CONTEXT_CHIP_ICON_SOURCES } from "./contextChipIcons.generated";
6import { resolveMarkdownFileIcon, type MarkdownFileIcon } from "./markdownLinks";
7import {
8 flattenThemeColor,
9 isDarkThemeColor,
10 themeAccentWithContrast,
11 type MobileThemeVariables,
12} from "./mobileTheme";
13
14export type ContextChipIcon = keyof typeof CONTEXT_CHIP_ICON_SOURCES;
15
16interface ContextChipPresentation {
17 readonly accent?: string;
18 readonly icon: ContextChipIcon | null;
19}
20
21/**
22 * These are the sRGB form of the same OKLCH set web uses, so a chip looks the same on every
23 * surface. See `ContextChip.tsx`.
24 */
25const CONTEXT_CHIP_PRESENTATIONS = {
26 image: { accent: "#0090cd", icon: "image" },
27 video: { accent: "#d06217", icon: "film" },
28 file: { accent: "#0090cd", icon: null },
29 mention: { accent: "#0096af", icon: null },
30 terminal: { accent: "#009f6e", icon: "terminal" },
31 element: { accent: "#b87501", icon: "mouse-pointer-click" },
32 "preview-annotation": {
33 accent: "#b87501",
34 icon: "mouse-pointer-click",
35 },
36 "review-comment": { accent: "#8a70dd", icon: "message-circle" },
37 "pull-request": {
38 accent: "#7079e4",
39 icon: "git-pull-request-arrow",
40 },
41 skill: { accent: "#b261be", icon: "skill" },
42 thread: {
43 accent: "#009c96",
44 icon: "messages-square",
45 },
46 // A machine: the record carries only its identity, so every machine wears one glyph.
47 environment: { accent: "#3f9a4f", icon: "server" },
48 // An earlier response the message replies to. Mobile shows it as a plain labelled chip.
49 response: { accent: "#7f8793", icon: null },
50} as const satisfies Record<string, ContextChipPresentation>;
51
52const CHIP_CONTRAST = 5;
53
54const readableChipAccents = new Map<string, string>();
55
56/**
57 * A chip keeps one color per theme so it looks the same in a message, a reply and the composer,
58 * which means it must read on all of them; the user bubble is usually the hardest. A surface
59 * whose text is the opposite shade, like Material You's primary-colored light bubble, can't share
60 * a color with the canvas, so chips are matched only against surfaces like the one they sit on.
61 */
62export function contextChipAccent(
63 accent: string | undefined,
64 theme: MobileThemeVariables,
65 textColor: string,
66): string | undefined {
67 if (!accent) return undefined;
68 const screen = theme["--color-screen"];
69 const darkText = isDarkThemeColor(textColor);
70 const surfaces = (
71 [
72 ["--color-user-bubble", "--color-user-bubble-foreground"],
73 ["--color-thread-canvas", "--color-md-body"],
74 ["--color-composer-surface", "--color-foreground"],
75 ] as const
76 )
77 .filter(([, text]) => isDarkThemeColor(theme[text]) === darkText)
78 .map(([surface]) => flattenThemeColor(theme[surface], screen));
79 const key = [accent, ...surfaces].join();
80 let readable = readableChipAccents.get(key);
81 if (readable === undefined) {
82 readable = themeAccentWithContrast(accent, surfaces, CHIP_CONTRAST);
83 readableChipAccents.set(key, readable);
84 }
85 return readable;
86}
87
88export const UNAVAILABLE_CONTEXT_CHIP_PRESENTATION: ContextChipPresentation = {
89 icon: "circle-dashed",
90};
91
92export function composerChipSizeSuffix(record?: {
93 readonly kind?: string;
94 readonly sizeBytes?: number;
95}): string {
96 if (record?.kind !== "file" && record?.kind !== "image") return "";
97 return typeof record.sizeBytes === "number" ? formatAttachmentSize(record.sizeBytes) : "";
98}
99
100const PULL_REQUEST_CHIP_PRESENTATIONS = {
101 open: { accent: "#009f6e", icon: "git-pull-request-arrow" },
102 draft: { accent: "#7f8793", icon: "git-pull-request-draft" },
103 merged: { accent: "#8a70dd", icon: "git-merge" },
104 closed: { accent: "#d55665", icon: "git-pull-request-closed" },
105} as const satisfies Record<string, ContextChipPresentation>;
106
107export function contextChipPresentation(
108 kind: string,
109 record?: {
110 readonly kind?: string;
111 readonly name?: string;
112 readonly mimeType?: string;
113 readonly sectionId?: string;
114 readonly pullRequest?: {
115 readonly state?: string;
116 readonly isDraft?: boolean;
117 };
118 },
119): ContextChipPresentation {
120 const presentationKind =
121 kind === "file" &&
122 videoMimeType({
123 name: record?.name ?? "",
124 mimeType: record?.mimeType ?? "",
125 })
126 ? "video"
127 : kind === "file" &&
128 imageMimeType({ name: record?.name ?? "", mimeType: record?.mimeType ?? "" }) !== null
129 ? "image"
130 : kind === "review-comment" && record?.sectionId?.startsWith("pull-request:")
131 ? "pull-request"
132 : kind;
133 if (presentationKind === "pull-request") {
134 const pullRequest = record?.pullRequest;
135 const state =
136 pullRequest?.state === "open" && pullRequest.isDraft === true
137 ? "draft"
138 : (pullRequest?.state ?? "");
139 if (Object.hasOwn(PULL_REQUEST_CHIP_PRESENTATIONS, state)) {
140 return PULL_REQUEST_CHIP_PRESENTATIONS[state as keyof typeof PULL_REQUEST_CHIP_PRESENTATIONS];
141 }
142 }
143 return Object.hasOwn(CONTEXT_CHIP_PRESENTATIONS, presentationKind)
144 ? CONTEXT_CHIP_PRESENTATIONS[presentationKind as keyof typeof CONTEXT_CHIP_PRESENTATIONS]
145 : UNAVAILABLE_CONTEXT_CHIP_PRESENTATION;
146}
147
148export type ContextChipIconRef =
149 | { readonly kind: "chip"; readonly name: ContextChipIcon }
150 | { readonly kind: "file"; readonly name: MarkdownFileIcon };
151
152export function contextChipIcon(
153 presentation: Pick<ContextChipPresentation, "icon">,
154 fileName: string,
155): ContextChipIconRef {
156 return presentation.icon
157 ? { kind: "chip", name: presentation.icon }
158 : { kind: "file", name: resolveMarkdownFileIcon(fileName) };
159}
160
161export function contextRecordFileName(record?: object): string {
162 if (record && "path" in record && typeof record.path === "string") return record.path;
163 if (record && "name" in record && typeof record.name === "string") return record.name;
164 return "";
165}