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(
6060 NSString *imageUri = [NSString stringWithUTF8String:attachmentRange.imageUri.c_str()];
6161 NSTextAttachment *attachment = [[NSTextAttachment alloc] init];
6262 UIImage *image = images[imageUri];
63 if ([imageUri hasPrefix:@"sf:"]) {
64 NSString *symbolName = [imageUri substringFromIndex:3];
65 UIColor *foregroundColor =
66 [attributedString attribute:NSForegroundColorAttributeName
67 atIndex:attachmentRange.location
68 effectiveRange:nil] ?: UIColor.labelColor;
69 image = [[UIImage systemImageNamed:symbolName] imageWithTintColor:foregroundColor
70 renderingMode:UIImageRenderingModeAlwaysOriginal];
63 const BOOL isSymbol = [imageUri hasPrefix:@"sf:"];
64 if (isSymbol) {
65 image = [UIImage systemImageNamed:[imageUri substringFromIndex:3]];
66 }
67 UIColor *foregroundColor = [attributedString attribute:NSForegroundColorAttributeName
68 atIndex:attachmentRange.location
69 effectiveRange:nil];
70 if (image != nil && (isSymbol || attachmentRange.tintWithForeground)) {
71 image = [image imageWithTintColor:foregroundColor ?: UIColor.labelColor
72 renderingMode:UIImageRenderingModeAlwaysOriginal];
7173 }
7274 attachment.image = image ?: [[UIImage alloc] init];
7375 const CGFloat attachmentSize = T3MarkdownTextAttachmentSize(attachmentRange);
......@@ -79,8 +81,15 @@ static void T3MarkdownTextApplyAttachments(
7981 const NSRange range = NSMakeRange(
8082 attachmentRange.location,
8183 MIN(attachmentRange.length, attributedString.length - attachmentRange.location));
82 NSAttributedString *attachmentString =
83 [NSAttributedString attributedStringWithAttachment:attachment];
84 NSMutableAttributedString *attachmentString =
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 }
8493 [attributedString replaceCharactersInRange:range withAttributedString:attachmentString];
8594 }
8695}
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h+2
......@@ -26,6 +26,8 @@ struct T3MarkdownTextAttachmentRange {
2626 size_t location;
2727 size_t length;
2828 std::string imageUri;
29 /// Recolor the loaded image with the run's foreground color, like `sf:` symbols.
30 bool tintWithForeground;
2931};
3032
3133inline Float T3MarkdownTextAttachmentSize(const T3MarkdownTextAttachmentRange &) {
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm+11
......@@ -11,6 +11,7 @@ namespace facebook::react {
1111static constexpr Float ParagraphStyleEncodingOffset = 1000;
1212static constexpr auto FileAttachmentNativeIdPrefix = "t3-file:";
1313static constexpr auto SkillAttachmentNativeIdPrefix = "t3-skill:";
14static constexpr auto LinkAttachmentNativeIdPrefix = "t3-link:";
1415
1516static void applyParagraphStyles(
1617 NSMutableAttributedString *attributedString,
......@@ -192,6 +193,7 @@ Size T3MarkdownTextShadowNode::measureContent(
192193 utf16Offset,
193194 1,
194195 props.nativeId.substr(std::char_traits<char>::length(FileAttachmentNativeIdPrefix)),
196 false,
195197 });
196198 } else if (
197199 props.nativeId.rfind(SkillAttachmentNativeIdPrefix, 0) == 0 && fragmentLength > 0) {
......@@ -200,6 +202,15 @@ Size T3MarkdownTextShadowNode::measureContent(
200202 1,
201203 props.nativeId.substr(
202204 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,
203214 });
204215 }
205216 utf16Offset += fragmentLength;
apps/mobile/modules/t3-markdown-text/package.json+1
......@@ -21,6 +21,7 @@
2121 "exports": {
2222 ".": "./index.ts",
2323 "./file-icons": "./src/markdownFileIcons.ts",
24 "./link-icons": "./src/markdownLinkIcons.ts",
2425 "./links": "./src/markdownLinks.ts",
2526 "./markdown": "./src/nativeMarkdownText.ts",
2627 "./primitive": "./src/MarkdownTextPrimitive.tsx",
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx+27-7
......@@ -12,6 +12,8 @@ import {
1212
1313import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive";
1414import { markdownFileIconSource } from "./markdownFileIcons";
15import { markdownLinkIconSource } from "./markdownLinkIcons";
16import { resolveMarkdownLinkIcon } from "./markdownLinks";
1517import type { NativeMarkdownTextRun } from "./nativeMarkdownText";
1618import type {
1719 MarkdownFileContextMenu,
......@@ -177,10 +179,14 @@ export function NativeMarkdownSelectableText(props: {
177179}) {
178180 const colorScheme = useColorScheme();
179181 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 );
181187 const attachAndroidText = useCallback(
182188 (textView: RNText | null) => {
183 if (Platform.OS !== "android" || !containsInlineFileIcon || textView === null) {
189 if (Platform.OS !== "android" || !containsInlineIcon || textView === null) {
184190 return;
185191 }
186192 const reactTag = findNodeHandle(textView);
......@@ -188,7 +194,7 @@ export function NativeMarkdownSelectableText(props: {
188194 installMarkdownCopySanitizer(reactTag);
189195 }
190196 },
191 [containsInlineFileIcon],
197 [containsInlineIcon],
192198 );
193199 const occurrences = new Map<string, number>();
194200 const prefixedExternalLinks = new Set<string>();
......@@ -198,6 +204,7 @@ export function NativeMarkdownSelectableText(props: {
198204 occurrences.set(signature, occurrence + 1);
199205
200206 let text = run.text;
207 let linkIcon = null;
201208 if (run.fileIcon && Platform.OS === "ios") {
202209 text = `${INLINE_ATTACHMENT_PREFIX}${text}`;
203210 } else if (run.skillName && run.skillLabel) {
......@@ -207,10 +214,15 @@ export function NativeMarkdownSelectableText(props: {
207214 : `$${run.skillName}`;
208215 } else if (run.externalHost && run.href && !prefixedExternalLinks.has(run.href)) {
209216 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 }
211223 }
212224
213 return { key: `${signature}:${occurrence}`, run, text };
225 return { key: `${signature}:${occurrence}`, run, text, linkIcon };
214226 });
215227 // T3MarkdownText only rebuilds its attributed string during native layout. A
216228 // color-only child update can otherwise leave the previous appearance cached.
......@@ -248,7 +260,7 @@ export function NativeMarkdownSelectableText(props: {
248260 lineHeight: props.textStyle.lineHeight,
249261 }}
250262 >
251 {keyedRuns.map(({ key, run, text }) => {
263 {keyedRuns.map(({ key, run, text, linkIcon }) => {
252264 const href = run.href;
253265 const contextMenu = run.fileIcon && href ? menu?.fileContextMenu(href) : undefined;
254266 return (
......@@ -260,7 +272,9 @@ export function NativeMarkdownSelectableText(props: {
260272 ? `t3-file:${Image.resolveAssetSource(markdownFileIconSource(run.fileIcon)).uri}`
261273 : run.skillName
262274 ? "t3-skill:sf:cube"
263 : undefined
275 : linkIcon
276 ? `t3-link:${Image.resolveAssetSource(markdownLinkIconSource(linkIcon)).uri}`
277 : undefined
264278 : undefined
265279 }
266280 contextMenuConfig={contextMenu ? JSON.stringify(contextMenu) : undefined}
......@@ -284,6 +298,12 @@ export function NativeMarkdownSelectableText(props: {
284298 >
285299 {Platform.OS === "android" && run.fileIcon ? (
286300 <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 />
287307 ) : null}
288308 {text}
289309 </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 =
3333
3434export 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
3648const FILE_ICON_BY_NAME: Readonly<Record<string, MarkdownFileIcon>> = {
3749 ".babelrc": "babel",
3850 ".babelrc.json": "babel",
apps/mobile/src/features/threads/ThreadFeed.tsx+13-4
......@@ -134,9 +134,11 @@ import {
134134import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
135135import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
136136import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";
137import { markdownLinkIconSource } from "@t3tools/mobile-markdown-text/link-icons";
137138import {
138139 normalizeNativeMarkdownUrl,
139140 resolveMarkdownInlineCodePresentation,
141 resolveMarkdownLinkIcon,
140142 resolveMarkdownLinkPresentation,
141143} from "@t3tools/mobile-markdown-text/links";
142144import {
......@@ -606,7 +608,8 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: {
606608 readonly onPress: (href: string) => void;
607609}) {
608610 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
611614 return (
612615 <NativeText
......@@ -617,9 +620,15 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: {
617620 textDecorationLine: "none",
618621 }}
619622 >
620 {faviconUrl !== null &&
621 failedHost !== props.host &&
622 !failedMarkdownFaviconHosts.has(props.host) ? (
623 {linkIcon ? (
624 <Image
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) ? (
623632 <Image
624633 source={{
625634 uri: faviconUrl,
apps/mobile/src/lib/markdownLinks.test.ts+15-1
......@@ -1,6 +1,20 @@
11import { 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
519describe("resolveMarkdownLinkPresentation", () => {
620 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";
55import { describe, expect, it, vi } from "vite-plus/test";
66
77import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting";
8import { GitHubIcon } from "./Icons";
89import { Button } from "./ui/button";
910import { setMarkdownTaskChecked } from "./files/filePreviewMode";
1011
......@@ -78,10 +79,10 @@ describe("ChatMarkdown favicon privacy", () => {
7879 const markdown = (url: string) => <ChatMarkdown cwd="/tmp/project" text={`[Link](${url})`} />;
7980 try {
8081 await act(async () => {
81 renderer = create(markdown("https://github.com"));
82 renderer = create(markdown("https://example.com"));
8283 });
8384 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",
8586 ]);
8687 for (const url of ["http://192.168.1.10:8080", "http://localhost:3000", "http://home.arpa"]) {
8788 await act(async () => {
......@@ -90,9 +91,15 @@ describe("ChatMarkdown favicon privacy", () => {
9091 expect(renderer!.root.findAllByType("img")).toHaveLength(0);
9192 }
9293 await act(async () => {
93 renderer!.update(markdown("https://github.com"));
94 renderer!.update(markdown("https://example.com"));
9495 });
9596 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);
96103 } finally {
97104 await act(async () => {
98105 renderer?.unmount();
apps/web/src/components/ChatMarkdown.tsx+13-2
......@@ -119,6 +119,7 @@ import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";
119119import { fnv1a32 } from "../lib/diffRendering";
120120import { LRUCache } from "../lib/lruCache";
121121import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting";
122import { GitHubIcon } from "./Icons";
122123import { RenderErrorBoundary } from "./RenderErrorBoundary";
123124import { useTheme } from "../hooks/useTheme";
124125import { getClientSettings, useClientSettings } from "../hooks/useSettings";
......@@ -1189,15 +1190,25 @@ const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none";
11891190/** Hosts whose favicon request already failed this session — skip straight to the globe. */
11901191const 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
11921200const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) {
11931201 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}`);
11951204 return (
11961205 <span
11971206 className="ms-[0.25em] me-[0.2em] inline-flex size-[14px] [vertical-align:-0.125em]"
11981207 aria-hidden
11991208 >
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) ? (
12011212 <GlobeIcon className={MARKDOWN_LINK_FAVICON_CLASS_NAME} />
12021213 ) : (
12031214 <img