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
4 changes: 2 additions & 2 deletions apps/mobile/deps/README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
The Enriched Markdown tarball comes from [`feat/t3chat-markdown-parity-v2`](https://github.com/pingdotgg/enriched-markdown/tree/feat/t3chat-markdown-parity-v2), based on Julius's [`fork/t3-mobile` branch](https://github.com/pingdotgg/enriched-markdown/tree/fork/t3-mobile). Its `t3-fork.json` records the exact source commit and upstream base. File/skill link recognition, back navigation from horizontal blocks, table-image presses, and code-block headers use upstream implementations. The fork retains clipboard metadata, parsed asset manifests, signed image source resolution, React media slots, and development-host autolinks. The fork retains native math parsing, scrolling and copying, table layout and gestures, and inline-code layout. Character-reference decoding, iOS intrinsic-width measurement, and HTML clipboard round trips now use the upstream fixes included in Julius's base. RaTeX uses the upstream-released 0.1.15 dependency.
The Enriched Markdown tarball comes from Julius's [`fork/t3-mobile` branch](https://github.com/pingdotgg/enriched-markdown/tree/fork/t3-mobile). Its `t3-fork.json` records the exact source commit and upstream base. React block renderers and signed image sources (`renderers`, `resolveImageSource`) are upstream. The fork carries only changes with an open upstream PR: the `onLinksChange` link list that link pills and iOS link menus read ([#1088](https://github.com/software-mansion/enriched-markdown/pull/1088)), Android inline image alignment ([#1089](https://github.com/software-mansion/enriched-markdown/pull/1089)), clipboard link metadata ([#962](https://github.com/software-mansion/enriched-markdown/pull/962)), and development-host autolinks ([#961](https://github.com/software-mansion/enriched-markdown/pull/961)).

To replace it, commit the fork changes, run `yarn install --immutable` and `yarn workspace react-native-enriched-markdown prepare` in the fork checkout, then `node scripts/pack-t3.mjs /path/to/t3code/apps/mobile/deps`. Keep the package version aligned with Julius's `fork/t3-mobile` branch. Rename the archive to include the first eight characters of the source commit, such as `react-native-enriched-markdown-1.2.0-t3.15-fee84114.tgz`, so each build has a distinct dependency path without version bumps. Update the tarball path in `apps/mobile/package.json` and `pnpm-workspace.yaml`'s `allowBuilds`, and run `vp i` to update the lockfile. Native clients need rebuilding when this dependency changes.
To replace it, commit the fork changes, run `yarn install --immutable` and `yarn workspace react-native-enriched-markdown prepare` in the fork checkout, then `node scripts/pack-t3.mjs /path/to/t3code/apps/mobile/deps`. Keep the package version aligned with Julius's `fork/t3-mobile` branch. Rename the archive to include the first eight characters of the source commit, such as `react-native-enriched-markdown-1.2.0-t3.16-8d2f84a8.tgz`, so each build has a distinct dependency path without version bumps. Update the tarball path in `apps/mobile/package.json` and `pnpm-workspace.yaml`'s `allowBuilds`, and run `vp i` to update the lockfile. Native clients need rebuilding when this dependency changes.
Binary file not shown.
Binary file not shown.
2 changes: 1 addition & 1 deletion apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@
"react-dom": "catalog:",
"react-native": "0.88.0",
"react-native-blob-util": "0.25.1",
"react-native-enriched-markdown": "file:deps/react-native-enriched-markdown-1.2.0-t3.15-fee84114.tgz",
"react-native-enriched-markdown": "file:deps/react-native-enriched-markdown-1.2.0-t3.16-8d2f84a8.tgz",
"react-native-gesture-handler": "~3.3.0",
"react-native-image-viewing": "^0.2.2",
"react-native-keyboard-controller": "1.22.7",
Expand Down
99 changes: 48 additions & 51 deletions apps/mobile/src/native/EnrichedMarkdownText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,20 @@ import {
markdownWithInlineFileLinks,
markdownWithTableBreaks,
} from "../lib/markdownDocument";
import { useCallback, useMemo, useState, type ReactNode } from "react";
import { isValidElement, useCallback, useMemo, useState, type ReactNode } from "react";
import { Platform, Share, StyleSheet, View } from "react-native";
import {
EnrichedMarkdownText,
type DocumentAssetsEvent,
type MarkdownImageBlock,
type MarkdownLinkOccurrence,
type MarkdownStyle,
type MarkdownMediaAsset,
} from "react-native-enriched-markdown";
import type { SelectableMarkdownTextProps } from "./SelectableMarkdownText.types";

import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
import { Menu } from "../components/ui/Menu";
import { useEnrichedLinkVariants } from "./useEnrichedLinkVariants";
import { useRetryingImageSource } from "./useRetryingImageSource";
import { copyTextWithHaptic } from "../lib/copyTextWithHaptic";
import {
ENRICHED_INLINE_FILE_LINK_REGEX,
Expand Down Expand Up @@ -164,33 +165,34 @@ export function MobileEnrichedMarkdownText(props: SelectableMarkdownTextProps) {
),
[props.markdown],
);
const [documentAssets, setDocumentAssets] = useState<DocumentAssetsEvent["assets"]>([]);
const onDocumentAssets = useCallback(({ assets }: DocumentAssetsEvent) => {
setDocumentAssets((previous) =>
previous.length === assets.length &&
previous.every((asset, index) => {
const next = assets[index]!;
return (
asset.id === next.id &&
asset.kind === next.kind &&
asset.url === next.url &&
asset.altText === next.altText &&
asset.title === next.title &&
asset.placement === next.placement &&
asset.eligible === next.eligible
);
})
const [parsedLinkUrls, setParsedLinkUrls] = useState<ReadonlyArray<string>>([]);
const onLinksChange = useCallback((links: MarkdownLinkOccurrence[]) => {
const urls = [...new Set(links.map((link) => link.url))];
setParsedLinkUrls((previous) =>
previous.length === urls.length && previous.every((url, index) => url === urls[index])
? previous
: assets,
: urls,
);
}, []);
const { linkVariants, linkPillContent } = useEnrichedLinkVariants(documentAssets, props);
const linkRecognition = useMemo(
() => ({
text: enrichedSkillLinkRegex(props.skills) ?? undefined,
}),
[props.skills],
const skillRegex = useMemo(() => enrichedSkillLinkRegex(props.skills), [props.skills]);
// Skill links come from linkRecognition, which runs as the text is drawn, so
// onLinksChange never reports them. Joined so a streaming tick keeps the value.
const skillLinkKey = useMemo(
() =>
skillRegex
? [...new Set(markdown.match(new RegExp(skillRegex.source, "gu")) ?? [])].join("\n")
: "",
[markdown, skillRegex],
);
const linkUrls = useMemo(
() =>
skillLinkKey
? [...new Set([...parsedLinkUrls, ...skillLinkKey.split("\n")])]
: parsedLinkUrls,
[parsedLinkUrls, skillLinkKey],
);
const { linkVariants, linkPillContent } = useEnrichedLinkVariants(linkUrls, props);
const linkRecognition = useMemo(() => ({ text: skillRegex ?? undefined }), [skillRegex]);
const [activeMenu, setActiveMenu] = useState<{
url: string;
menu: NonNullable<ReturnType<NonNullable<typeof props.fileContextMenu>>>;
Expand All @@ -209,11 +211,7 @@ export function MobileEnrichedMarkdownText(props: SelectableMarkdownTextProps) {
() =>
Platform.OS === "ios" && (fileContextMenu || onLinkPress)
? Object.fromEntries(
[
...new Set(
documentAssets.filter((asset) => asset.kind === "link").map((asset) => asset.url),
),
].map((url) => {
linkUrls.map((url) => {
const menu = fileContextMenu?.(url) ?? {
title: url,
actions: [
Expand All @@ -233,7 +231,7 @@ export function MobileEnrichedMarkdownText(props: SelectableMarkdownTextProps) {
}),
)
: undefined,
[documentAssets, fileContextMenu, onLinkPress, performMenuAction],
[linkUrls, fileContextMenu, onLinkPress, performMenuAction],
);
const selectionClipboard = useMemo(() => {
const fragment = decodeComposerContextFragment(props.contextClipboardFragment);
Expand Down Expand Up @@ -264,24 +262,23 @@ export function MobileEnrichedMarkdownText(props: SelectableMarkdownTextProps) {
() => ({ ...enrichedStyle(props.textStyle, themeAppearance === "dark"), linkVariants }),
[props.textStyle, themeAppearance, linkVariants],
);
const renderMedia = useCallback(
(asset: MarkdownMediaAsset) =>
renderImage?.({
href: asset.url,
alt: asset.altText || null,
title: asset.title || null,
}) ?? null,
const renderers = useMemo(
() =>
renderImage
? {
image: (block: MarkdownImageBlock) => {
const element = renderImage({
href: block.url,
alt: block.altText || null,
title: block.title || null,
});
return isValidElement(element) ? element : undefined;
},
}
: undefined,
[renderImage],
);
const resolveNativeImageSource = useCallback(
(asset: MarkdownMediaAsset) =>
resolveImageSource?.({
href: asset.url,
alt: asset.altText || null,
title: asset.title || null,
}) ?? null,
[resolveImageSource],
);
const resolveNativeImageSource = useRetryingImageSource(resolveImageSource);
const renderMarkdown = (openAndroidMenu?: () => void) => {
const onLinkLongPress =
Platform.OS === "android" && props.fileContextMenu
Expand Down Expand Up @@ -310,9 +307,9 @@ export function MobileEnrichedMarkdownText(props: SelectableMarkdownTextProps) {
<EnrichedMarkdownText
markdown={markdown}
markdownStyle={markdownStyle}
onDocumentAssets={onDocumentAssets}
renderMedia={renderImage ? renderMedia : undefined}
resolveImageSource={resolveImageSource ? resolveNativeImageSource : undefined}
onLinksChange={onLinksChange}
renderers={renderers}
resolveImageSource={resolveNativeImageSource}
onImagePress={
props.onImagePress || props.onLinkPress
? ({ url }) => (props.onImagePress ?? props.onLinkPress)?.(url)
Expand Down
18 changes: 7 additions & 11 deletions apps/mobile/src/native/useEnrichedLinkVariants.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
import { useMemo } from "react";
import { Image } from "react-native";
import type {
LinkPillContent,
MarkdownDocumentAsset,
MarkdownStyle,
} from "react-native-enriched-markdown";
import type { LinkPillContent, MarkdownStyle } from "react-native-enriched-markdown";
import type { ComposerContextRecord } from "@t3tools/contracts";
import { contextChipAccent, contextChipPresentation } from "../lib/composerChipPresentation";
import { CONTEXT_CHIP_ICON_SOURCES } from "../lib/contextChipIcons.generated";
Expand Down Expand Up @@ -94,20 +90,20 @@ function presentation(
}

export function useEnrichedLinkVariants(
assets: ReadonlyArray<MarkdownDocumentAsset>,
urls: ReadonlyArray<string>,
props: SelectableMarkdownTextProps,
) {
const { linkCustomization, skills, textStyle } = props;
const theme = useUniwindTheme();
const links = useMemo(() => {
const byUrl = new Map<string, MarkdownLinkCustomization>();
for (const asset of assets) {
if (asset.kind !== "link" || byUrl.has(asset.url)) continue;
const value = presentation(asset.url, { linkCustomization, skills, textStyle });
if (value) byUrl.set(asset.url, value);
for (const url of urls) {
if (byUrl.has(url)) continue;
const value = presentation(url, { linkCustomization, skills, textStyle });
if (value) byUrl.set(url, value);
}
return byUrl;
}, [assets, linkCustomization, skills, textStyle]);
}, [urls, linkCustomization, skills, textStyle]);
const chipForegrounds = useMemo(
() =>
new Map(
Expand Down
47 changes: 47 additions & 0 deletions apps/mobile/src/native/useRetryingImageSource.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { act, useLayoutEffect } from "react";
import { createRoot } from "react-dom/client";
import { expect, it } from "vite-plus/test";
import type { MarkdownImageSourceResolver } from "./SelectableMarkdownText.types";
import { useRetryingImageSource } from "./useRetryingImageSource";

let resolveImage: ReturnType<typeof useRetryingImageSource>;

function Probe(props: { readonly resolve: MarkdownImageSourceResolver }) {
const resolver = useRetryingImageSource(props.resolve);
useLayoutEffect(() => {
resolveImage = resolver;
});
return null;
}

it("asks the next resolver for an image the previous one could not resolve", async () => {
const root = createRoot(document.createElement("div"));
const offline: MarkdownImageSourceResolver = async () => {
throw new Error("Markdown image is unavailable");
};
await act(async () => root.render(<Probe resolve={offline} />));
const askedOnce = resolveImage!;

let source: unknown = "pending";
const answered = askedOnce({
id: "img:0:0",
url: "assets/shot.png",
altText: "",
title: "",
placement: "inline",
}).then((value) => {
source = value;
});
await act(async () => {});
expect(source).toBe("pending");

const online: MarkdownImageSourceResolver = async (image) => ({
uri: `https://host/${image.href}`,
});
await act(async () => root.render(<Probe resolve={online} />));
await answered;

expect(source).toEqual({ uri: "https://host/assets/shot.png" });
expect(resolveImage).toBe(askedOnce);
act(() => root.unmount());
});
43 changes: 43 additions & 0 deletions apps/mobile/src/native/useRetryingImageSource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useLayoutEffect, useMemo, useRef } from "react";
import type { MarkdownImageOccurrence, MarkdownImageSource } from "react-native-enriched-markdown";
import type { MarkdownImageSourceResolver } from "./SelectableMarkdownText.types";

/**
* Enriched asks about each image once and reads `resolveImageSource` like an
* event handler, so a failed image would keep its placeholder until remount.
* Our resolver fails while the environment is unreachable and is replaced when
* that changes, so a failed image stays pending and asks the next resolver.
*/
export function useRetryingImageSource(resolve: MarkdownImageSourceResolver | undefined) {
const latestRef = useRef(resolve);
const retriesRef = useRef(new Set<() => void>());
useLayoutEffect(() => {
latestRef.current = resolve;
const retries = [...retriesRef.current];
retriesRef.current.clear();
for (const retry of retries) retry();
}, [resolve]);

const enabled = resolve !== undefined;
return useMemo(() => {
if (!enabled) return undefined;
return (image: MarkdownImageOccurrence) =>
new Promise<MarkdownImageSource | undefined>((settle) => {
const request = { href: image.url, alt: image.altText || null, title: image.title || null };
const attempt = () => {
const current = latestRef.current;
if (!current) return;
Promise.resolve()
.then(() => current(request))
.then(
(source) => settle(source ?? undefined),
() => {
if (latestRef.current !== current) attempt();
else retriesRef.current.add(attempt);
},
);
};
attempt();
});
}, [enabled]);
}
1 change: 1 addition & 0 deletions apps/mobile/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const reactTests = [
"src/features/settings/components/BranchNamingSettings.test.tsx",
"src/native/NativeHeaderToolbar.test.tsx",
"src/native/useRestingTopOffset.test.tsx",
"src/native/useRetryingImageSource.test.tsx",
"src/state/use-selected-thread-git-actions.test.tsx",
];

Expand Down
12 changes: 6 additions & 6 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ allowBuilds:
node-pty: true
# Ships N-API prebuilds; its install script only rebuilds from source.
objc-js: false
"react-native-enriched-markdown@file:apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.15-fee84114.tgz": true
"react-native-enriched-markdown@file:apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.16-8d2f84a8.tgz": true
sharp: true
"@tsc-rs/typescript": true
utf-8-validate: false
Expand Down
Loading