Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 72 additions & 6 deletions apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { MarkdownNode } from "react-native-nitro-markdown/headless";
import { isMarkdownFileLinkLabel } from "@t3tools/client-runtime/markdown-links";
import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens";
import { imageMimeType } from "@t3tools/shared/image";
import { isWindowsAbsolutePath } from "@t3tools/shared/path";
Expand Down Expand Up @@ -109,19 +110,34 @@ export function nativeMarkdownContextCopyRanges(
}>,
) {
let offset = 0;
return runs.flatMap(({ run, text, inlineImageLength }) => {
const ranges: Array<{ start: number; end: number; text: string }> = [];
for (const [index, { run, text, inlineImageLength }] of runs.entries()) {
const start = offset;
offset += text.length + inlineImageLength;
const reference = parseComposerContextHref(run.href ?? "");
const source = reference
? formatComposerContextReference({ ...reference, label: run.text })
: run.skillName
? `$${run.skillName}`
: run.fileIcon && run.href
? (run.sourceText ?? `[${run.text}](<${run.href}>)`)
: null;
return source === null ? [] : [{ start, end: offset, text: source }];
});
: run.sourceText !== undefined
? run.sourceText
: run.fileIcon && run.href
? `[${run.text}](<${run.href}>)`
: null;
if (source === null) continue;
const previous = ranges.at(-1);
if (
run.sourceText !== undefined &&
!runs[index - 1]?.run.fileIcon &&
previous?.end === start &&
previous.text === source
) {
previous.end = offset;
} else {
ranges.push({ start, end: offset, text: source });
}
}
return ranges;
}

import type { SelectableMarkdownSkill } from "./SelectableMarkdownText.types";
Expand Down Expand Up @@ -181,6 +197,7 @@ interface RunContext {
readonly href?: string;
readonly externalHost?: string;
readonly fileIcon?: MarkdownFileIcon;
readonly sourceText?: string;
readonly role?: NativeMarkdownTextRun["role"];
readonly headingLevel?: number;
readonly depth?: number;
Expand Down Expand Up @@ -268,6 +285,7 @@ function sameRunStyle(left: NativeMarkdownTextRun, right: NativeMarkdownTextRun)
left.href === right.href &&
left.externalHost === right.externalHost &&
left.fileIcon === right.fileIcon &&
left.sourceText === right.sourceText &&
left.skillName === right.skillName &&
left.skillLabel === right.skillLabel &&
left.role === right.role &&
Expand Down Expand Up @@ -298,6 +316,7 @@ function appendRun(
...(context.href ? { href: context.href } : {}),
...(context.externalHost ? { externalHost: context.externalHost } : {}),
...(context.fileIcon ? { fileIcon: context.fileIcon } : {}),
...(context.sourceText !== undefined ? { sourceText: context.sourceText } : {}),
...(context.role ? { role: context.role } : {}),
...(context.headingLevel ? { headingLevel: context.headingLevel } : {}),
...(context.depth ? { depth: context.depth } : {}),
Expand Down Expand Up @@ -432,6 +451,36 @@ function nodeTextContent(node: MarkdownNode): string {
return (node.children ?? []).map(nodeTextContent).join("");
}

function fileLinkLabelMarkdown(node: MarkdownNode): string {
const children = (node.children ?? []).map(fileLinkLabelMarkdown).join("");
switch (node.type) {
case "bold":
return `**${children}**`;
case "italic":
return `*${children}*`;
case "strikethrough":
return `~~${children}~~`;
case "code_inline": {
const content = nodeTextContent(node);
const fence = "`".repeat(
Math.max(0, ...(content.match(/`+/g) ?? []).map((run) => run.length)) + 1,
);
const padding = /^`|`$|^ .* $/.test(content) && content.trim().length > 0 ? " " : "";
return `${fence}${padding}${content}${padding}${fence}`;
}
case "soft_break":
return "\n";
case "line_break":
return " \n";
case "text":
return textNodeContent(nodeTextContent(node)).replace(/[\\[\]*_`~]/g, "\\$&");
case "image":
return `![${(node.alt ?? "").replace(/[\\[\]]/g, "\\$&")}](<${node.href ?? ""}>)`;
default:
return node.content ?? children;
}
}

function appendNode(
runs: NativeMarkdownTextRun[],
node: MarkdownNode,
Expand Down Expand Up @@ -482,6 +531,23 @@ function appendNode(
}
const presentation = resolveMarkdownLinkPresentation(node.href ?? "");
if (presentation.kind === "file") {
const label = textNodeContent(nodeTextContent(node));
if (!isMarkdownFileLinkLabel(label, presentation.href)) {
const fileRuns: NativeMarkdownTextRun[] = [];
const fileContext = {
...context,
href: presentation.href,
sourceText: `[${fileLinkLabelMarkdown(node)}](<${presentation.href}>)`,
};
appendChildren(fileRuns, node, fileContext);
appendRun(fileRuns, " ", fileContext);
appendRun(fileRuns, presentation.label, {
...fileContext,
fileIcon: presentation.icon,
});
runs.push(...fileRuns);
return runs;
}
return appendRun(runs, presentation.label, {
...context,
href: presentation.href,
Expand Down
62 changes: 39 additions & 23 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ import {
type CodexArtifactTemplate,
} from "@t3tools/client-runtime/codex-artifact-templates";
import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
import { isMarkdownFileLinkLabel } from "@t3tools/client-runtime/markdown-links";
import { getTextContent, type MarkdownNode } from "react-native-nitro-markdown/headless";
import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";
import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
import {
Expand Down Expand Up @@ -721,7 +723,7 @@ interface MarkdownStyleSet {
}

const failedMarkdownFaviconHosts = new Set<string>();
const MarkdownLinkLabelContext = createContext(false);
const MarkdownLinkLabelContext = createContext<"file" | "other" | null>(null);
const markdownLinkStyles = StyleSheet.create({
inlineIcon: {
width: 14,
Expand Down Expand Up @@ -813,6 +815,22 @@ function MarkdownInlineCode(props: {
);
}

function MarkdownImage(props: {
readonly node: MarkdownNode;
readonly renderImage: MarkdownImageRenderer;
}) {
const insideLink = useContext(MarkdownLinkLabelContext);
if (insideLink === "file")
return <NativeText>{props.node.alt ?? props.node.title ?? ""}</NativeText>;
return props.node.href
? props.renderImage({
Comment thread
maria-rcks marked this conversation as resolved.
href: props.node.href,
alt: props.node.alt ?? null,
title: props.node.title ?? null,
})
: null;
}

const ARTIFACT_TEMPLATE_SYMBOL_BY_KIND: Record<
CodexArtifactTemplate["artifactKind"],
AppSymbolName
Expand Down Expand Up @@ -1183,26 +1201,31 @@ function useMarkdownStyles(
preserveSoftBreaks: boolean,
highlightCode: boolean,
): CustomRenderers => ({
link: ({ children, href = "" }) => {
link: ({ children, node, href = "" }) => {
const presentation = resolveMarkdownLinkPresentation(href);
if (presentation.kind === "file") {
return (
<NativeText
className="font-t3-bold"
onPress={() => onLinkPress(href)}
style={{ color: inlineTextColor }}
>
<Image
source={markdownFileIconSource(presentation.icon)}
style={markdownLinkStyles.inlineIcon}
/>
{presentation.label}
</NativeText>
<MarkdownLinkLabelContext.Provider value="file">
<NativeText onPress={() => onLinkPress(href)} style={{ color: inlineTextColor }}>
{!isMarkdownFileLinkLabel(getTextContent(node), href) && <>{children} </>}
Comment thread
maria-rcks marked this conversation as resolved.
<NativeText
className="font-t3-bold"
onPress={() => onLinkPress(href)}
style={{ color: inlineTextColor }}
>
<Image
source={markdownFileIconSource(presentation.icon)}
style={markdownLinkStyles.inlineIcon}
/>
{presentation.label}
</NativeText>
</NativeText>
</MarkdownLinkLabelContext.Provider>
);
}
if (presentation.kind === "external") {
return (
<MarkdownLinkLabelContext.Provider value>
<MarkdownLinkLabelContext.Provider value="other">
<MarkdownExternalLink
href={presentation.href}
host={presentation.host}
Expand All @@ -1216,7 +1239,7 @@ function useMarkdownStyles(
}
const linkHref = presentation.href;
return (
<MarkdownLinkLabelContext.Provider value>
<MarkdownLinkLabelContext.Provider value="other">
<NativeText
className="underline"
onPress={
Expand Down Expand Up @@ -1265,14 +1288,7 @@ function useMarkdownStyles(
})}
</View>
),
image: ({ node }) =>
node.href
? (renderImage({
href: node.href,
alt: node.alt ?? null,
title: node.title ?? null,
}) ?? undefined)
: undefined,
image: ({ node }) => <MarkdownImage node={node} renderImage={renderImage} />,
code_inline: ({ content }) => (
<MarkdownInlineCode
content={content ?? ""}
Expand Down
104 changes: 103 additions & 1 deletion apps/mobile/src/lib/nativeMarkdownText.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ describe("nativeMarkdownTextRuns", () => {
{
type: "link",
href: "file:///repo/README.md#L12",
children: [{ type: "text", content: "ignored label" }],
children: [{ type: "text", content: "validates the input" }],
},
],
};
Expand All @@ -150,14 +150,116 @@ describe("nativeMarkdownTextRuns", () => {
externalHost: "example.com",
},
{ text: " " },
{
text: "validates the input ",
href: "file:///repo/README.md#L12",
sourceText: "[validates the input](<file:///repo/README.md#L12>)",
},
{
text: "README.md:12",
href: "file:///repo/README.md#L12",
fileIcon: "markdown",
sourceText: "[validates the input](<file:///repo/README.md#L12>)",
},
]);
});

it.each([true, false])("copies descriptive file links with collapsed chips=%s", (collapsed) => {
const link: MarkdownNode = {
type: "link",
href: "/repo/src/example.ts:12",
children: [
{ type: "text", content: "validates " },
{ type: "bold", children: [{ type: "text", content: "the input" }] },
],
};
const runs = nativeMarkdownTextRuns({ type: "paragraph", children: [link, link] });
expect(runs.map((run) => run.text).join("")).toBe(
"validates the input example.ts:12validates the input example.ts:12",
);
expect(runs.filter((run) => run.bold).map((run) => run.text)).toEqual([
"the input",
"the input",
]);
const ranges = nativeMarkdownContextCopyRanges(
runs.map((run) => ({
run,
text: collapsed && run.fileIcon ? "" : run.text,
inlineImageLength: run.fileIcon ? 1 : 0,
})),
);
const length = "validates the input ".length + (collapsed ? 1 : "example.ts:12".length + 1);
expect(ranges).toEqual([
{ start: 0, end: length, text: "[validates **the input**](</repo/src/example.ts:12>)" },
{
start: length,
end: length * 2,
text: "[validates **the input**](</repo/src/example.ts:12>)",
},
]);
});

it("keeps filename-labelled links as chips", () => {
expect(
nativeMarkdownTextRuns({
type: "paragraph",
children: [
{
type: "link",
href: "/repo/src/example.ts:12",
children: [{ type: "code_inline", content: "src/example.ts:12" }],
},
],
}),
).toEqual([{ text: "example.ts:12", href: "/repo/src/example.ts:12", fileIcon: "typescript" }]);
});

it.each([
[{ type: "italic", children: [{ type: "text", content: "details" }] }, "*details*"],
[{ type: "strikethrough", children: [{ type: "text", content: "details" }] }, "~~details~~"],
[{ type: "code_inline", content: "`details`" }, "`` `details` ``"],
[{ type: "code_inline", content: " details " }, "` details `"],
[{ type: "text", content: "[details] *literal*" }, "\\[details\\] \\*literal\\*"],
[{ type: "soft_break" }, "\n"],
[{ type: "line_break" }, " \n"],
[
{ type: "image", alt: "details", href: "https://example.com/icon.png" },
"![details](<https://example.com/icon.png>)",
],
] satisfies ReadonlyArray<readonly [MarkdownNode, string]>)(
"keeps descriptive label markup when copying %j",
(child, source) => {
const runs = nativeMarkdownTextRuns({
type: "paragraph",
children: [
{
type: "link",
href: "/repo/a.ts",
children: [{ type: "text", content: "see " }, child],
},
],
});
expect(
nativeMarkdownContextCopyRanges(
runs.map((run) => ({
run,
text: run.fileIcon ? "" : run.text,
inlineImageLength: run.fileIcon ? 1 : 0,
})),
),
).toEqual([
{
start: 0,
end:
runs
.filter((run) => !run.fileIcon)
.reduce((length, run) => length + run.text.length, 0) + 1,
text: `[see ${source}](</repo/a.ts>)`,
},
]);
},
);

it("keeps hard breaks and collapses soft breaks", () => {
const node: MarkdownNode = {
type: "paragraph",
Expand Down
Loading
Loading