1import { decodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard";
2import {
3 collectComposerContextReferences,
4 formatComposerContextHref,
5 formatComposerContextReference,
6} from "@t3tools/shared/composerContextReferences";
7import { markdownWithHtml } from "../lib/markdownDocument";
8import { useCallback, useMemo, useState } from "react";
9import { ActionSheetIOS, Platform, Share, StyleSheet, View } from "react-native";
10import {
11 EnrichedMarkdownText,
12 type DocumentAssetsEvent,
13 type MarkdownStyle,
14 type MarkdownMediaAsset,
15} from "react-native-enriched-markdown";
16import type { SelectableMarkdownTextProps } from "./SelectableMarkdownText.types";
17
18import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
19import { AndroidAnchoredMenu } from "../components/AndroidAnchoredMenu";
20import { useEnrichedLinkVariants } from "./useEnrichedLinkVariants";
21import { copyTextWithHaptic } from "../lib/copyTextWithHaptic";
22import {
23 ENRICHED_INLINE_FILE_LINK_REGEX,
24 enrichedSkillLinkRegex,
25 enrichedLinkVariantPattern,
26} from "../lib/enrichedLinkPresentation";
27
28function enrichedStyle(
29 style: SelectableMarkdownTextProps["textStyle"],
30 dark: boolean,
31): MarkdownStyle {
32 const monospace = Platform.OS === "ios" ? "Menlo" : "monospace";
33 // Keep the radius modest: iOS clips square table-cell borders without stroking the corner arcs.
34 const block = {
35 marginTop: 4,
36 marginBottom: 12,
37 borderRadius: 8,
38 borderWidth: StyleSheet.hairlineWidth,
39 borderColor: style.dividerColor,
40 };
41 const body = {
42 color: style.color,
43 fontFamily: style.fontFamily,
44 fontSize: style.fontSize,
45 lineHeight: style.lineHeight,
46 marginTop: 0,
47 marginBottom: 8,
48 };
49 const heading = (level: number) => {
50 const fontSize = style.headingFontSizes?.[level - 1] ?? style.fontSize * (1.6 - level * 0.1);
51 return {
52 ...body,
53 color: style.strongColor,
54 fontFamily: style.headingFontFamily,
55 fontSize,
56 lineHeight: fontSize * 1.35,
57 marginTop: 8,
58 };
59 };
60 return {
61 paragraph: body,
62 h1: heading(1),
63 h2: heading(2),
64 h3: heading(3),
65 h4: heading(4),
66 h5: heading(5),
67 h6: heading(6),
68 strong: { color: style.strongColor, fontFamily: style.boldFontFamily, fontWeight: "normal" },
69 link: { color: style.linkColor, underline: false },
70 code: {
71 fontFamily: monospace,
72 color: style.inlineCodeColor,
73 backgroundColor: "transparent",
74 borderColor: "transparent",
75 fontSize: style.fontSize * 0.9,
76 },
77 codeBlock: {
78 ...body,
79 color: style.codeColor,
80 backgroundColor: style.codeBlockBackgroundColor,
81 fontFamily: monospace,
82 fontSize: style.fontSize * 0.9,
83 ...block,
84 padding: 12,
85 syntaxColors: dark
86 ? {
87 keyword: "#ff7b72",
88 string: "#a5d6ff",
89 number: "#79c0ff",
90 constant: "#79c0ff",
91 comment: "#8b949e",
92 function: "#d2a8ff",
93 type: "#ffa657",
94 property: "#79c0ff",
95 tag: "#7ee787",
96 attribute: "#79c0ff",
97 }
98 : {
99 keyword: "#cf222e",
100 string: "#0a3069",
101 number: "#0550ae",
102 constant: "#0550ae",
103 comment: "#6e7781",
104 function: "#8250df",
105 type: "#953800",
106 property: "#0550ae",
107 tag: "#116329",
108 attribute: "#0550ae",
109 },
110 },
111 blockquote: {
112 ...body,
113 color: style.mutedColor,
114 backgroundColor: "transparent",
115 borderColor: style.quoteMarkerColor,
116 borderWidth: 2,
117 gapWidth: 12,
118 },
119 list: { ...body, bulletColor: style.mutedColor, markerColor: style.mutedColor, gapWidth: 6 },
120 thematicBreak: { color: style.dividerColor, height: 1, marginTop: 8, marginBottom: 8 },
121 table: {
122 ...body,
123 ...block,
124 headerFontFamily: style.boldFontFamily,
125 headerTextColor: style.strongColor,
126 headerBackgroundColor: style.codeBlockBackgroundColor,
127 rowEvenBackgroundColor: "transparent",
128 rowOddBackgroundColor: "transparent",
129 cellPaddingHorizontal: 10,
130 cellPaddingVertical: 7,
131 },
132 taskList: {
133 checkedColor: style.linkColor,
134 checkedTextColor: style.color,
135 borderColor: style.mutedColor,
136 checkedStrikethrough: false,
137 },
138 };
139}
140
141export function MobileEnrichedMarkdownText(props: SelectableMarkdownTextProps) {
142 const { renderImage, resolveImageSource } = props;
143 const markdown = useMemo(() => markdownWithHtml(props.markdown), [props.markdown]);
144 const { themeAppearance } = useAppearancePreferences();
145 const [documentAssets, setDocumentAssets] = useState<DocumentAssetsEvent["assets"]>([]);
146 const onDocumentAssets = useCallback(({ assets }: DocumentAssetsEvent) => {
147 setDocumentAssets((previous) =>
148 previous.length === assets.length &&
149 previous.every((asset, index) => {
150 const next = assets[index]!;
151 return (
152 asset.id === next.id &&
153 asset.kind === next.kind &&
154 asset.url === next.url &&
155 asset.altText === next.altText &&
156 asset.title === next.title &&
157 asset.placement === next.placement &&
158 asset.eligible === next.eligible
159 );
160 })
161 ? previous
162 : assets,
163 );
164 }, []);
165 const { linkVariants, linkPillContent } = useEnrichedLinkVariants(documentAssets, props);
166 const linkRecognition = useMemo(
167 () => ({
168 text: enrichedSkillLinkRegex(props.skills) ?? undefined,
169 inlineCode: ENRICHED_INLINE_FILE_LINK_REGEX,
170 }),
171 [props.skills],
172 );
173 const [activeMenu, setActiveMenu] = useState<{
174 url: string;
175 menu: NonNullable<ReturnType<NonNullable<typeof props.fileContextMenu>>>;
176 } | null>(null);
177 const { fileContextMenu, onLinkPress, onFileContextMenuAction } = props;
178 const performMenuAction = useCallback(
179 (url: string, actionId: string) => {
180 if (actionId === "enriched-open-link") onLinkPress?.(url);
181 else if (actionId === "enriched-copy-link") copyTextWithHaptic(url);
182 else if (actionId === "enriched-share-link") void Share.share({ message: url });
183 else onFileContextMenuAction?.(url, actionId);
184 },
185 [onLinkPress, onFileContextMenuAction],
186 );
187 const linkContextMenuItems = useMemo(
188 () =>
189 Platform.OS === "ios" && (fileContextMenu || onLinkPress)
190 ? Object.fromEntries(
191 [
192 ...new Set(
193 documentAssets.filter((asset) => asset.kind === "link").map((asset) => asset.url),
194 ),
195 ].map((url) => {
196 const menu = fileContextMenu?.(url) ?? {
197 title: url,
198 actions: [
199 { id: "enriched-open-link", title: "Open link" },
200 { id: "enriched-copy-link", title: "Copy link" },
201 { id: "enriched-share-link", title: "Share link" },
202 ],
203 };
204 return [
205 enrichedLinkVariantPattern(url),
206 menu.actions.map((action) => ({
207 text: action.title,
208 disabled: "disabled" in action && action.disabled,
209 onPress: () => performMenuAction(url, action.id),
210 })),
211 ];
212 }),
213 )
214 : undefined,
215 [documentAssets, fileContextMenu, onLinkPress, performMenuAction],
216 );
217 const selectionClipboard = useMemo(() => {
218 const fragment = decodeComposerContextFragment(props.contextClipboardFragment);
219 if (!fragment) return undefined;
220 const references = collectComposerContextReferences(props.markdown);
221 const linkTextByUrl = Object.fromEntries(
222 references.flatMap((reference) => {
223 const record = fragment.records.find((entry) => entry.contextId === reference.contextId);
224 return record
225 ? [
226 [
227 formatComposerContextHref(reference.kind, reference.contextId),
228 formatComposerContextReference({ ...reference, label: record.label }),
229 ],
230 ]
231 : [];
232 }),
233 );
234 return {
235 linkTextByUrl,
236 htmlAttributes: {
237 "data-t3-context-fragment": encodeURIComponent(props.contextClipboardFragment!),
238 },
239 mimeTypes: { "app.t3.context-fragment": props.contextClipboardFragment! },
240 };
241 }, [props.contextClipboardFragment, props.markdown]);
242 const markdownStyle = useMemo(
243 () => ({ ...enrichedStyle(props.textStyle, themeAppearance === "dark"), linkVariants }),
244 [props.textStyle, themeAppearance, linkVariants],
245 );
246 const renderMedia = useCallback(
247 (asset: MarkdownMediaAsset) =>
248 renderImage?.({
249 href: asset.url,
250 alt: asset.altText || null,
251 title: asset.title || null,
252 }) ?? null,
253 [renderImage],
254 );
255 const resolveNativeImageSource = useCallback(
256 (asset: MarkdownMediaAsset) =>
257 resolveImageSource?.({
258 href: asset.url,
259 alt: asset.altText || null,
260 title: asset.title || null,
261 }) ?? null,
262 [resolveImageSource],
263 );
264 const renderMarkdown = (openAndroidMenu?: () => void) => {
265 const onLinkLongPress = props.fileContextMenu
266 ? ({ url }: { url: string }) => {
267 const menu = props.fileContextMenu?.(url) ?? {
268 title: url,
269 actions: [
270 { id: "enriched-open-link", title: "Open link" },
271 { id: "enriched-copy-link", title: "Copy link" },
272 ],
273 };
274 const actions = menu.actions;
275 if (Platform.OS === "ios") {
276 ActionSheetIOS.showActionSheetWithOptions(
277 {
278 title: menu.title,
279 options: [...actions.map((action) => action.title), "Cancel"],
280 cancelButtonIndex: actions.length,
281 disabledButtonIndices: actions.flatMap((action, index) =>
282 action.disabled ? [index] : [],
283 ),
284 },
285 (index) => {
286 const action = actions[index];
287 if (action && !action.disabled) performMenuAction(url, action.id);
288 },
289 );
290 } else {
291 setActiveMenu({ url, menu });
292 openAndroidMenu?.();
293 }
294 }
295 : undefined;
296
297 return (
298 <View
299 style={{
300 flexShrink: 1,
301 minWidth: 0,
302 marginTop: props.marginTop,
303 marginBottom: props.marginBottom,
304 }}
305 >
306 <EnrichedMarkdownText
307 markdown={markdown}
308 markdownStyle={markdownStyle}
309 onDocumentAssets={onDocumentAssets}
310 renderMedia={renderImage ? renderMedia : undefined}
311 resolveImageSource={resolveImageSource ? resolveNativeImageSource : undefined}
312 onImagePress={
313 props.onImagePress || props.onLinkPress
314 ? ({ url }) => (props.onImagePress ?? props.onLinkPress)?.(url)
315 : undefined
316 }
317 linkRecognition={linkRecognition}
318 containerStyle={{ flexShrink: 1, minWidth: 0 }}
319 flavor="github"
320 selectable
321 selectionClipboard={selectionClipboard}
322 selectionColor={props.textStyle.selectionColor}
323 selectionHandleColor={props.textStyle.selectionHandleColor}
324 md4cFlags={{
325 latexMath: false,
326 hardSoftBreaks: props.preserveSoftBreaks ?? false,
327 admonitions: true,
328 }}
329 enableTaskListItemToggle={false}
330 spoilerOverlay="solid"
331 onLinkPress={props.onLinkPress ? ({ url }) => props.onLinkPress?.(url) : undefined}
332 onLinkLongPress={onLinkLongPress}
333 linkContextMenuItems={linkContextMenuItems}
334 linkPillContent={linkPillContent}
335 />
336 </View>
337 );
338 };
339
340 return Platform.OS === "android" && props.fileContextMenu ? (
341 <AndroidAnchoredMenu
342 title={activeMenu?.menu.title}
343 actions={
344 activeMenu?.menu.actions.map((action) => ({
345 id: action.id,
346 title: action.title,
347 attributes: { disabled: action.disabled },
348 })) ?? []
349 }
350 onPressAction={({ nativeEvent }) => {
351 if (activeMenu) performMenuAction(activeMenu.url, nativeEvent.event);
352 }}
353 style={{ flexShrink: 1, minWidth: 0 }}
354 >
355 {renderMarkdown}
356 </AndroidAnchoredMenu>
357 ) : (
358 renderMarkdown()
359 );
360}