| author | |
| committer | |
| log | 272d6d747ef214f50dbd9d0051120de12ffa3591 |
| tree | b928bea6bbe9c9ab5f69a9b4a60907fa77b3ffbc |
| parent | a495385584276d0e568df23646a49ce8b40a4707 |
| signature | Signed by PGP key B5690EEEBB952194 |
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:NSForegroundColorAttributeName | 66 | } |
| 67 | atIndex:attachmentRange.location | 67 | UIColor *foregroundColor = [attributedString attribute:NSForegroundColorAttributeName |
| 68 | effectiveRange:nil] ?: UIColor.labelColor; | 68 | atIndex:attachmentRange.location |
| 69 | image = [[UIImage systemImageNamed:symbolName] imageWithTintColor:foregroundColor | 69 | 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 | }; |
| 30 | 32 | ||
| 31 | inline Float T3MarkdownTextAttachmentSize(const T3MarkdownTextAttachmentRange &) { | 33 | inline 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 { |
| 11 | static constexpr Float ParagraphStyleEncodingOffset = 1000; | 11 | static constexpr Float ParagraphStyleEncodingOffset = 1000; |
| 12 | static constexpr auto FileAttachmentNativeIdPrefix = "t3-file:"; | 12 | static constexpr auto FileAttachmentNativeIdPrefix = "t3-file:"; |
| 13 | static constexpr auto SkillAttachmentNativeIdPrefix = "t3-skill:"; | 13 | static constexpr auto SkillAttachmentNativeIdPrefix = "t3-skill:"; |
| 14 | static constexpr auto LinkAttachmentNativeIdPrefix = "t3-link:"; | ||
| 14 | 15 | ||
| 15 | static void applyParagraphStyles( | 16 | static 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 { |
| 12 | 12 | ||
| 13 | import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; | 13 | import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; |
| 14 | import { markdownFileIconSource } from "./markdownFileIcons"; | 14 | import { markdownFileIconSource } from "./markdownFileIcons"; |
| 15 | import { markdownLinkIconSource } from "./markdownLinkIcons"; | ||
| 16 | import { resolveMarkdownLinkIcon } from "./markdownLinks"; | ||
| 15 | import type { NativeMarkdownTextRun } from "./nativeMarkdownText"; | 17 | import type { NativeMarkdownTextRun } from "./nativeMarkdownText"; |
| 16 | import type { | 18 | import 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); |
| 199 | 205 | ||
| 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 | } |
| 212 | 224 | ||
| 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. A | 227 | // 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.skillName | 273 | : run.skillName |
| 262 | ? "t3-skill:sf:cube" | 274 | ? "t3-skill:sf:cube" |
| 263 | : undefined | 275 | : linkIcon |
| 276 | ? `t3-link:${Image.resolveAssetSource(markdownLinkIconSource(linkIcon)).uri}` | ||
| 277 | : undefined | ||
| 264 | : undefined | 278 | : 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 @@ | ||
| 1 | import type { ImageSourcePropType } from "react-native"; | ||
| 2 | |||
| 3 | import type { MarkdownLinkIcon } from "./markdownLinks"; | ||
| 4 | |||
| 5 | // Black-on-transparent marks; callers tint them with the link color. | ||
| 6 | const MARKDOWN_LINK_ICON_SOURCES = { | ||
| 7 | github: require("../assets/link-icons/github.png"), | ||
| 8 | } as const satisfies Readonly<Record<MarkdownLinkIcon, ImageSourcePropType>>; | ||
| 9 | |||
| 10 | export 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 = |
| 33 | 33 | ||
| 34 | export type MarkdownFileIcon = keyof typeof MARKDOWN_FILE_ICON_SOURCES; | 34 | export type MarkdownFileIcon = keyof typeof MARKDOWN_FILE_ICON_SOURCES; |
| 35 | 35 | ||
| 36 | export 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 | */ | ||
| 42 | export 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 | |||
| 36 | const FILE_ICON_BY_NAME: Readonly<Record<string, MarkdownFileIcon>> = { | 48 | const 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 { |
| 134 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | 134 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 135 | import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; | 135 | import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; |
| 136 | import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; | 136 | import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; |
| 137 | import { markdownLinkIconSource } from "@t3tools/mobile-markdown-text/link-icons"; | ||
| 137 | import { | 138 | import { |
| 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"; |
| 142 | import { | 144 | import { |
| ... | @@ -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}`); | ||
| 610 | 613 | ||
| 611 | return ( | 614 | return ( |
| 612 | <NativeText | 615 | <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 | <Image | 632 | <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 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; | 3 | import { |
| 4 | resolveMarkdownLinkIcon, | ||
| 5 | resolveMarkdownLinkPresentation, | ||
| 6 | } from "@t3tools/mobile-markdown-text/links"; | ||
| 7 | |||
| 8 | describe("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 | }); | ||
| 4 | 18 | ||
| 5 | describe("resolveMarkdownLinkPresentation", () => { | 19 | describe("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"; |
| 5 | import { describe, expect, it, vi } from "vite-plus/test"; | 5 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 6 | 6 | ||
| 7 | import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; | 7 | import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; |
| 8 | import { GitHubIcon } from "./Icons"; | ||
| 8 | import { Button } from "./ui/button"; | 9 | import { Button } from "./ui/button"; |
| 9 | import { setMarkdownTaskChecked } from "./files/filePreviewMode"; | 10 | import { setMarkdownTaskChecked } from "./files/filePreviewMode"; |
| 10 | 11 | ||
| ... | @@ -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"; |
| 119 | import { fnv1a32 } from "../lib/diffRendering"; | 119 | import { fnv1a32 } from "../lib/diffRendering"; |
| 120 | import { LRUCache } from "../lib/lruCache"; | 120 | import { LRUCache } from "../lib/lruCache"; |
| 121 | import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; | 121 | import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; |
| 122 | import { GitHubIcon } from "./Icons"; | ||
| 122 | import { RenderErrorBoundary } from "./RenderErrorBoundary"; | 123 | import { RenderErrorBoundary } from "./RenderErrorBoundary"; |
| 123 | import { useTheme } from "../hooks/useTheme"; | 124 | import { useTheme } from "../hooks/useTheme"; |
| 124 | import { getClientSettings, useClientSettings } from "../hooks/useSettings"; | 125 | import { 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. */ |
| 1190 | const failedFaviconHosts = new Set<string>(); | 1191 | const failedFaviconHosts = new Set<string>(); |
| 1191 | 1192 | ||
| 1193 | /** Sites whose brand mark (drawn in `currentColor`) replaces the fetched favicon so it follows the theme. */ | ||
| 1194 | function 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 | |||
| 1192 | const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) { | 1200 | const 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 | <span | 1205 | <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-hidden | 1207 | 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 | <img | 1214 | <img |