authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-05 23:21:44-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-05 23:21:44-07:00
log272d6d747ef214f50dbd9d0051120de12ffa3591
treeb928bea6bbe9c9ab5f69a9b4a60907fa77b3ffbc
parenta495385584276d0e568df23646a49ce8b40a4707
signature Signed by PGP key B5690EEEBB952194

feat(markdown): show the GitHub mark for github.com links (#10324)

Co-authored-by: Claude Code <noreply@anthropic.com>

12 files changed, 135 insertions(+), 27 deletions(-)

apps/mobile/modules/t3-markdown-text/assets/link-icons/github.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/link-icons/github.png differ
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm+19-10
...@@ -60,14 +60,16 @@ static void T3MarkdownTextApplyAttachments(...@@ -60,14 +60,16 @@ static void T3MarkdownTextApplyAttachments(
60 NSString *imageUri = [NSString stringWithUTF8String:attachmentRange.imageUri.c_str()];60 NSString *imageUri = [NSString stringWithUTF8String:attachmentRange.imageUri.c_str()];
61 NSTextAttachment *attachment = [[NSTextAttachment alloc] init];61 NSTextAttachment *attachment = [[NSTextAttachment alloc] init];
62 UIImage *image = images[imageUri];62 UIImage *image = images[imageUri];
63 if ([imageUri hasPrefix:@"sf:"]) {63 const BOOL isSymbol = [imageUri hasPrefix:@"sf:"];
64 NSString *symbolName = [imageUri substringFromIndex:3];64 if (isSymbol) {
65 UIColor *foregroundColor =65 image = [UIImage systemImageNamed:[imageUri substringFromIndex:3]];
66 [attributedString attribute:NSForegroundColorAttributeName66 }
67 atIndex:attachmentRange.location67 UIColor *foregroundColor = [attributedString attribute:NSForegroundColorAttributeName
68 effectiveRange:nil] ?: UIColor.labelColor;68 atIndex:attachmentRange.location
69 image = [[UIImage systemImageNamed:symbolName] imageWithTintColor:foregroundColor69 effectiveRange:nil];
70 renderingMode:UIImageRenderingModeAlwaysOriginal];70 if (image != nil && (isSymbol || attachmentRange.tintWithForeground)) {
71 image = [image imageWithTintColor:foregroundColor ?: UIColor.labelColor
72 renderingMode:UIImageRenderingModeAlwaysOriginal];
71 }73 }
72 attachment.image = image ?: [[UIImage alloc] init];74 attachment.image = image ?: [[UIImage alloc] init];
73 const CGFloat attachmentSize = T3MarkdownTextAttachmentSize(attachmentRange);75 const CGFloat attachmentSize = T3MarkdownTextAttachmentSize(attachmentRange);
...@@ -79,8 +81,15 @@ static void T3MarkdownTextApplyAttachments(...@@ -79,8 +81,15 @@ static void T3MarkdownTextApplyAttachments(
79 const NSRange range = NSMakeRange(81 const NSRange range = NSMakeRange(
80 attachmentRange.location,82 attachmentRange.location,
81 MIN(attachmentRange.length, attributedString.length - attachmentRange.location));83 MIN(attachmentRange.length, attributedString.length - attachmentRange.location));
82 NSAttributedString *attachmentString =84 NSMutableAttributedString *attachmentString =
83 [NSAttributedString attributedStringWithAttachment:attachment];85 [[NSAttributedString attributedStringWithAttachment:attachment] mutableCopy];
86 // Keep the run color on the attachment so a later re-apply (after the image
87 // loads asynchronously) still tints with the link color, not labelColor.
88 if (foregroundColor != nil) {
89 [attachmentString addAttribute:NSForegroundColorAttributeName
90 value:foregroundColor
91 range:NSMakeRange(0, attachmentString.length)];
92 }
84 [attributedString replaceCharactersInRange:range withAttributedString:attachmentString];93 [attributedString replaceCharactersInRange:range withAttributedString:attachmentString];
85 }94 }
86}95}
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h+2
...@@ -26,6 +26,8 @@ struct T3MarkdownTextAttachmentRange {...@@ -26,6 +26,8 @@ struct T3MarkdownTextAttachmentRange {
26 size_t location;26 size_t location;
27 size_t length;27 size_t length;
28 std::string imageUri;28 std::string imageUri;
29 /// Recolor the loaded image with the run's foreground color, like `sf:` symbols.
30 bool tintWithForeground;
29};31};
3032
31inline Float T3MarkdownTextAttachmentSize(const T3MarkdownTextAttachmentRange &) {33inline Float T3MarkdownTextAttachmentSize(const T3MarkdownTextAttachmentRange &) {
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm+11
...@@ -11,6 +11,7 @@ namespace facebook::react {...@@ -11,6 +11,7 @@ namespace facebook::react {
11static constexpr Float ParagraphStyleEncodingOffset = 1000;11static constexpr Float ParagraphStyleEncodingOffset = 1000;
12static constexpr auto FileAttachmentNativeIdPrefix = "t3-file:";12static constexpr auto FileAttachmentNativeIdPrefix = "t3-file:";
13static constexpr auto SkillAttachmentNativeIdPrefix = "t3-skill:";13static constexpr auto SkillAttachmentNativeIdPrefix = "t3-skill:";
14static constexpr auto LinkAttachmentNativeIdPrefix = "t3-link:";
1415
15static void applyParagraphStyles(16static void applyParagraphStyles(
16 NSMutableAttributedString *attributedString,17 NSMutableAttributedString *attributedString,
...@@ -192,6 +193,7 @@ Size T3MarkdownTextShadowNode::measureContent(...@@ -192,6 +193,7 @@ Size T3MarkdownTextShadowNode::measureContent(
192 utf16Offset,193 utf16Offset,
193 1,194 1,
194 props.nativeId.substr(std::char_traits<char>::length(FileAttachmentNativeIdPrefix)),195 props.nativeId.substr(std::char_traits<char>::length(FileAttachmentNativeIdPrefix)),
196 false,
195 });197 });
196 } else if (198 } else if (
197 props.nativeId.rfind(SkillAttachmentNativeIdPrefix, 0) == 0 && fragmentLength > 0) {199 props.nativeId.rfind(SkillAttachmentNativeIdPrefix, 0) == 0 && fragmentLength > 0) {
...@@ -200,6 +202,15 @@ Size T3MarkdownTextShadowNode::measureContent(...@@ -200,6 +202,15 @@ Size T3MarkdownTextShadowNode::measureContent(
200 1,202 1,
201 props.nativeId.substr(203 props.nativeId.substr(
202 std::char_traits<char>::length(SkillAttachmentNativeIdPrefix)),204 std::char_traits<char>::length(SkillAttachmentNativeIdPrefix)),
205 false,
206 });
207 } else if (
208 props.nativeId.rfind(LinkAttachmentNativeIdPrefix, 0) == 0 && fragmentLength > 0) {
209 attachmentRanges.push_back(T3MarkdownTextAttachmentRange{
210 utf16Offset,
211 1,
212 props.nativeId.substr(std::char_traits<char>::length(LinkAttachmentNativeIdPrefix)),
213 true,
203 });214 });
204 }215 }
205 utf16Offset += fragmentLength;216 utf16Offset += fragmentLength;
apps/mobile/modules/t3-markdown-text/package.json+1
...@@ -21,6 +21,7 @@...@@ -21,6 +21,7 @@
21 "exports": {21 "exports": {
22 ".": "./index.ts",22 ".": "./index.ts",
23 "./file-icons": "./src/markdownFileIcons.ts",23 "./file-icons": "./src/markdownFileIcons.ts",
24 "./link-icons": "./src/markdownLinkIcons.ts",
24 "./links": "./src/markdownLinks.ts",25 "./links": "./src/markdownLinks.ts",
25 "./markdown": "./src/nativeMarkdownText.ts",26 "./markdown": "./src/nativeMarkdownText.ts",
26 "./primitive": "./src/MarkdownTextPrimitive.tsx",27 "./primitive": "./src/MarkdownTextPrimitive.tsx",
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx+27-7
...@@ -12,6 +12,8 @@ import {...@@ -12,6 +12,8 @@ import {
1212
13import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive";13import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive";
14import { markdownFileIconSource } from "./markdownFileIcons";14import { markdownFileIconSource } from "./markdownFileIcons";
15import { markdownLinkIconSource } from "./markdownLinkIcons";
16import { resolveMarkdownLinkIcon } from "./markdownLinks";
15import type { NativeMarkdownTextRun } from "./nativeMarkdownText";17import type { NativeMarkdownTextRun } from "./nativeMarkdownText";
16import type {18import type {
17 MarkdownFileContextMenu,19 MarkdownFileContextMenu,
...@@ -177,10 +179,14 @@ export function NativeMarkdownSelectableText(props: {...@@ -177,10 +179,14 @@ export function NativeMarkdownSelectableText(props: {
177}) {179}) {
178 const colorScheme = useColorScheme();180 const colorScheme = useColorScheme();
179 const menu = useContext(MarkdownFileContextMenuContext);181 const menu = useContext(MarkdownFileContextMenuContext);
180 const containsInlineFileIcon = props.runs.some((run) => run.fileIcon != null);182 const containsInlineIcon = props.runs.some(
183 (run) =>
184 run.fileIcon != null ||
185 (run.externalHost != null && resolveMarkdownLinkIcon(run.externalHost) !== null),
186 );
181 const attachAndroidText = useCallback(187 const attachAndroidText = useCallback(
182 (textView: RNText | null) => {188 (textView: RNText | null) => {
183 if (Platform.OS !== "android" || !containsInlineFileIcon || textView === null) {189 if (Platform.OS !== "android" || !containsInlineIcon || textView === null) {
184 return;190 return;
185 }191 }
186 const reactTag = findNodeHandle(textView);192 const reactTag = findNodeHandle(textView);
...@@ -188,7 +194,7 @@ export function NativeMarkdownSelectableText(props: {...@@ -188,7 +194,7 @@ export function NativeMarkdownSelectableText(props: {
188 installMarkdownCopySanitizer(reactTag);194 installMarkdownCopySanitizer(reactTag);
189 }195 }
190 },196 },
191 [containsInlineFileIcon],197 [containsInlineIcon],
192 );198 );
193 const occurrences = new Map<string, number>();199 const occurrences = new Map<string, number>();
194 const prefixedExternalLinks = new Set<string>();200 const prefixedExternalLinks = new Set<string>();
...@@ -198,6 +204,7 @@ export function NativeMarkdownSelectableText(props: {...@@ -198,6 +204,7 @@ export function NativeMarkdownSelectableText(props: {
198 occurrences.set(signature, occurrence + 1);204 occurrences.set(signature, occurrence + 1);
199205
200 let text = run.text;206 let text = run.text;
207 let linkIcon = null;
201 if (run.fileIcon && Platform.OS === "ios") {208 if (run.fileIcon && Platform.OS === "ios") {
202 text = `${INLINE_ATTACHMENT_PREFIX}${text}`;209 text = `${INLINE_ATTACHMENT_PREFIX}${text}`;
203 } else if (run.skillName && run.skillLabel) {210 } else if (run.skillName && run.skillLabel) {
...@@ -207,10 +214,15 @@ export function NativeMarkdownSelectableText(props: {...@@ -207,10 +214,15 @@ export function NativeMarkdownSelectableText(props: {
207 : `$${run.skillName}`;214 : `$${run.skillName}`;
208 } else if (run.externalHost && run.href && !prefixedExternalLinks.has(run.href)) {215 } else if (run.externalHost && run.href && !prefixedExternalLinks.has(run.href)) {
209 prefixedExternalLinks.add(run.href);216 prefixedExternalLinks.add(run.href);
210 text = `${EXTERNAL_LINK_PREFIX}${text}`;217 linkIcon = resolveMarkdownLinkIcon(run.externalHost);
218 if (linkIcon === null) {
219 text = `${EXTERNAL_LINK_PREFIX}${text}`;
220 } else if (Platform.OS === "ios") {
221 text = `${INLINE_ATTACHMENT_PREFIX}${text}`;
222 }
211 }223 }
212224
213 return { key: `${signature}:${occurrence}`, run, text };225 return { key: `${signature}:${occurrence}`, run, text, linkIcon };
214 });226 });
215 // T3MarkdownText only rebuilds its attributed string during native layout. A227 // T3MarkdownText only rebuilds its attributed string during native layout. A
216 // color-only child update can otherwise leave the previous appearance cached.228 // color-only child update can otherwise leave the previous appearance cached.
...@@ -248,7 +260,7 @@ export function NativeMarkdownSelectableText(props: {...@@ -248,7 +260,7 @@ export function NativeMarkdownSelectableText(props: {
248 lineHeight: props.textStyle.lineHeight,260 lineHeight: props.textStyle.lineHeight,
249 }}261 }}
250 >262 >
251 {keyedRuns.map(({ key, run, text }) => {263 {keyedRuns.map(({ key, run, text, linkIcon }) => {
252 const href = run.href;264 const href = run.href;
253 const contextMenu = run.fileIcon && href ? menu?.fileContextMenu(href) : undefined;265 const contextMenu = run.fileIcon && href ? menu?.fileContextMenu(href) : undefined;
254 return (266 return (
...@@ -260,7 +272,9 @@ export function NativeMarkdownSelectableText(props: {...@@ -260,7 +272,9 @@ export function NativeMarkdownSelectableText(props: {
260 ? `t3-file:${Image.resolveAssetSource(markdownFileIconSource(run.fileIcon)).uri}`272 ? `t3-file:${Image.resolveAssetSource(markdownFileIconSource(run.fileIcon)).uri}`
261 : run.skillName273 : run.skillName
262 ? "t3-skill:sf:cube"274 ? "t3-skill:sf:cube"
263 : undefined275 : linkIcon
276 ? `t3-link:${Image.resolveAssetSource(markdownLinkIconSource(linkIcon)).uri}`
277 : undefined
264 : undefined278 : undefined
265 }279 }
266 contextMenuConfig={contextMenu ? JSON.stringify(contextMenu) : undefined}280 contextMenuConfig={contextMenu ? JSON.stringify(contextMenu) : undefined}
...@@ -284,6 +298,12 @@ export function NativeMarkdownSelectableText(props: {...@@ -284,6 +298,12 @@ export function NativeMarkdownSelectableText(props: {
284 >298 >
285 {Platform.OS === "android" && run.fileIcon ? (299 {Platform.OS === "android" && run.fileIcon ? (
286 <Image source={markdownFileIconSource(run.fileIcon)} style={styles.inlineIcon} />300 <Image source={markdownFileIconSource(run.fileIcon)} style={styles.inlineIcon} />
301 ) : Platform.OS === "android" && linkIcon ? (
302 <Image
303 source={markdownLinkIconSource(linkIcon)}
304 style={styles.inlineIcon}
305 tintColor={props.textStyle.linkColor}
306 />
287 ) : null}307 ) : null}
288 {text}308 {text}
289 </MarkdownTextPrimitive>309 </MarkdownTextPrimitive>
apps/mobile/modules/t3-markdown-text/src/markdownLinkIcons.ts created+12
...@@ -0,0 +1,12 @@
1import type { ImageSourcePropType } from "react-native";
2
3import type { MarkdownLinkIcon } from "./markdownLinks";
4
5// Black-on-transparent marks; callers tint them with the link color.
6const MARKDOWN_LINK_ICON_SOURCES = {
7 github: require("../assets/link-icons/github.png"),
8} as const satisfies Readonly<Record<MarkdownLinkIcon, ImageSourcePropType>>;
9
10export function markdownLinkIconSource(icon: MarkdownLinkIcon): ImageSourcePropType {
11 return MARKDOWN_LINK_ICON_SOURCES[icon];
12}
apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts+12
...@@ -33,6 +33,18 @@ export type MarkdownLinkPresentation =...@@ -33,6 +33,18 @@ export type MarkdownLinkPresentation =
3333
34export type MarkdownFileIcon = keyof typeof MARKDOWN_FILE_ICON_SOURCES;34export type MarkdownFileIcon = keyof typeof MARKDOWN_FILE_ICON_SOURCES;
3535
36export type MarkdownLinkIcon = "github";
37
38/**
39 * Sites whose brand mark replaces the generic external-link glyph. The marks
40 * are monochrome and tinted with the link color, so they follow the theme.
41 */
42export function resolveMarkdownLinkIcon(host: string): MarkdownLinkIcon | null {
43 const hostname = host.toLowerCase();
44 if (hostname === "github.com" || hostname.endsWith(".github.com")) return "github";
45 return null;
46}
47
36const FILE_ICON_BY_NAME: Readonly<Record<string, MarkdownFileIcon>> = {48const FILE_ICON_BY_NAME: Readonly<Record<string, MarkdownFileIcon>> = {
37 ".babelrc": "babel",49 ".babelrc": "babel",
38 ".babelrc.json": "babel",50 ".babelrc.json": "babel",
apps/mobile/src/features/threads/ThreadFeed.tsx+13-4
...@@ -134,9 +134,11 @@ import {...@@ -134,9 +134,11 @@ import {
134import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";134import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
135import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";135import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
136import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";136import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";
137import { markdownLinkIconSource } from "@t3tools/mobile-markdown-text/link-icons";
137import {138import {
138 normalizeNativeMarkdownUrl,139 normalizeNativeMarkdownUrl,
139 resolveMarkdownInlineCodePresentation,140 resolveMarkdownInlineCodePresentation,
141 resolveMarkdownLinkIcon,
140 resolveMarkdownLinkPresentation,142 resolveMarkdownLinkPresentation,
141} from "@t3tools/mobile-markdown-text/links";143} from "@t3tools/mobile-markdown-text/links";
142import {144import {
...@@ -606,7 +608,8 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: {...@@ -606,7 +608,8 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: {
606 readonly onPress: (href: string) => void;608 readonly onPress: (href: string) => void;
607}) {609}) {
608 const [failedHost, setFailedHost] = useState<string | null>(null);610 const [failedHost, setFailedHost] = useState<string | null>(null);
609 const faviconUrl = faviconUrlForOrigin(`https://${props.host}`);611 const linkIcon = resolveMarkdownLinkIcon(props.host);
612 const faviconUrl = linkIcon ? null : faviconUrlForOrigin(`https://${props.host}`);
610613
611 return (614 return (
612 <NativeText615 <NativeText
...@@ -617,9 +620,15 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: {...@@ -617,9 +620,15 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: {
617 textDecorationLine: "none",620 textDecorationLine: "none",
618 }}621 }}
619 >622 >
620 {faviconUrl !== null &&623 {linkIcon ? (
621 failedHost !== props.host &&624 <Image
622 !failedMarkdownFaviconHosts.has(props.host) ? (625 source={markdownLinkIconSource(linkIcon)}
626 style={markdownLinkStyles.inlineIcon}
627 tintColor={props.color}
628 />
629 ) : faviconUrl !== null &&
630 failedHost !== props.host &&
631 !failedMarkdownFaviconHosts.has(props.host) ? (
623 <Image632 <Image
624 source={{633 source={{
625 uri: faviconUrl,634 uri: faviconUrl,
apps/mobile/src/lib/markdownLinks.test.ts+15-1
...@@ -1,6 +1,20 @@...@@ -1,6 +1,20 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
22
3import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links";3import {
4 resolveMarkdownLinkIcon,
5 resolveMarkdownLinkPresentation,
6} from "@t3tools/mobile-markdown-text/links";
7
8describe("resolveMarkdownLinkIcon", () => {
9 it("gives GitHub hosts the brand mark and everything else the generic glyph", () => {
10 expect(resolveMarkdownLinkIcon("github.com")).toBe("github");
11 expect(resolveMarkdownLinkIcon("GitHub.com")).toBe("github");
12 expect(resolveMarkdownLinkIcon("gist.github.com")).toBe("github");
13 expect(resolveMarkdownLinkIcon("github.community")).toBeNull();
14 expect(resolveMarkdownLinkIcon("notgithub.com")).toBeNull();
15 expect(resolveMarkdownLinkIcon("example.com")).toBeNull();
16 });
17});
418
5describe("resolveMarkdownLinkPresentation", () => {19describe("resolveMarkdownLinkPresentation", () => {
6 it("treats protocol-relative media as an external URL, not a filesystem path", () => {20 it("treats protocol-relative media as an external URL, not a filesystem path", () => {
apps/web/src/components/ChatMarkdown.test.tsx+10-3
...@@ -5,6 +5,7 @@ import { create, type ReactTestRenderer } from "react-test-renderer";...@@ -5,6 +5,7 @@ import { create, type ReactTestRenderer } from "react-test-renderer";
5import { describe, expect, it, vi } from "vite-plus/test";5import { describe, expect, it, vi } from "vite-plus/test";
66
7import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting";7import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting";
8import { GitHubIcon } from "./Icons";
8import { Button } from "./ui/button";9import { Button } from "./ui/button";
9import { setMarkdownTaskChecked } from "./files/filePreviewMode";10import { setMarkdownTaskChecked } from "./files/filePreviewMode";
1011
...@@ -78,10 +79,10 @@ describe("ChatMarkdown favicon privacy", () => {...@@ -78,10 +79,10 @@ describe("ChatMarkdown favicon privacy", () => {
78 const markdown = (url: string) => <ChatMarkdown cwd="/tmp/project" text={`[Link](${url})`} />;79 const markdown = (url: string) => <ChatMarkdown cwd="/tmp/project" text={`[Link](${url})`} />;
79 try {80 try {
80 await act(async () => {81 await act(async () => {
81 renderer = create(markdown("https://github.com"));82 renderer = create(markdown("https://example.com"));
82 });83 });
83 expect(renderer!.root.findAllByType("img").map((image) => image.props.src)).toEqual([84 expect(renderer!.root.findAllByType("img").map((image) => image.props.src)).toEqual([
84 "https://www.google.com/s2/favicons?domain=github.com&sz=32",85 "https://www.google.com/s2/favicons?domain=example.com&sz=32",
85 ]);86 ]);
86 for (const url of ["http://192.168.1.10:8080", "http://localhost:3000", "http://home.arpa"]) {87 for (const url of ["http://192.168.1.10:8080", "http://localhost:3000", "http://home.arpa"]) {
87 await act(async () => {88 await act(async () => {
...@@ -90,9 +91,15 @@ describe("ChatMarkdown favicon privacy", () => {...@@ -90,9 +91,15 @@ describe("ChatMarkdown favicon privacy", () => {
90 expect(renderer!.root.findAllByType("img")).toHaveLength(0);91 expect(renderer!.root.findAllByType("img")).toHaveLength(0);
91 }92 }
92 await act(async () => {93 await act(async () => {
93 renderer!.update(markdown("https://github.com"));94 renderer!.update(markdown("https://example.com"));
94 });95 });
95 expect(renderer!.root.findAllByType("img")).toHaveLength(1);96 expect(renderer!.root.findAllByType("img")).toHaveLength(1);
97 // GitHub links draw the brand mark in currentColor instead of fetching a favicon.
98 await act(async () => {
99 renderer!.update(markdown("https://github.com/pingdotgg/t3code/pull/1"));
100 });
101 expect(renderer!.root.findAllByType("img")).toHaveLength(0);
102 expect(renderer!.root.findAllByType(GitHubIcon)).toHaveLength(1);
96 } finally {103 } finally {
97 await act(async () => {104 await act(async () => {
98 renderer?.unmount();105 renderer?.unmount();
apps/web/src/components/ChatMarkdown.tsx+13-2
...@@ -119,6 +119,7 @@ import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";...@@ -119,6 +119,7 @@ import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";
119import { fnv1a32 } from "../lib/diffRendering";119import { fnv1a32 } from "../lib/diffRendering";
120import { LRUCache } from "../lib/lruCache";120import { LRUCache } from "../lib/lruCache";
121import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting";121import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting";
122import { GitHubIcon } from "./Icons";
122import { RenderErrorBoundary } from "./RenderErrorBoundary";123import { RenderErrorBoundary } from "./RenderErrorBoundary";
123import { useTheme } from "../hooks/useTheme";124import { useTheme } from "../hooks/useTheme";
124import { getClientSettings, useClientSettings } from "../hooks/useSettings";125import { getClientSettings, useClientSettings } from "../hooks/useSettings";
...@@ -1189,15 +1190,25 @@ const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none";...@@ -1189,15 +1190,25 @@ const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none";
1189/** Hosts whose favicon request already failed this session — skip straight to the globe. */1190/** Hosts whose favicon request already failed this session — skip straight to the globe. */
1190const failedFaviconHosts = new Set<string>();1191const failedFaviconHosts = new Set<string>();
11911192
1193/** Sites whose brand mark (drawn in `currentColor`) replaces the fetched favicon so it follows the theme. */
1194function brandLinkIcon(host: string): typeof GitHubIcon | null {
1195 const hostname = host.toLowerCase();
1196 if (hostname === "github.com" || hostname.endsWith(".github.com")) return GitHubIcon;
1197 return null;
1198}
1199
1192const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) {1200const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) {
1193 const [failedHost, setFailedHost] = useState<string | null>(null);1201 const [failedHost, setFailedHost] = useState<string | null>(null);
1194 const faviconUrl = faviconUrlForOrigin(`https://${host}`);1202 const BrandIcon = brandLinkIcon(host);
1203 const faviconUrl = BrandIcon ? null : faviconUrlForOrigin(`https://${host}`);
1195 return (1204 return (
1196 <span1205 <span
1197 className="ms-[0.25em] me-[0.2em] inline-flex size-[14px] [vertical-align:-0.125em]"1206 className="ms-[0.25em] me-[0.2em] inline-flex size-[14px] [vertical-align:-0.125em]"
1198 aria-hidden1207 aria-hidden
1199 >1208 >
1200 {faviconUrl === null || failedHost === host || failedFaviconHosts.has(host) ? (1209 {BrandIcon ? (
1210 <BrandIcon className={MARKDOWN_LINK_FAVICON_CLASS_NAME} />
1211 ) : faviconUrl === null || failedHost === host || failedFaviconHosts.has(host) ? (
1201 <GlobeIcon className={MARKDOWN_LINK_FAVICON_CLASS_NAME} />1212 <GlobeIcon className={MARKDOWN_LINK_FAVICON_CLASS_NAME} />
1202 ) : (1213 ) : (
1203 <img1214 <img