Skip to content

Commit 5ac5778

Browse files
authored
Merge pull request #251 from pylon-code/upstream/2026-09-02-media-preview
fix(media): preview host files and stream videos across clients
2 parents 4ebe4b8 + 8e98cef commit 5ac5778

83 files changed

Lines changed: 4369 additions & 702 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/desktop/src/electron/ElectronProtocol.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,13 @@ describe("ElectronProtocol", () => {
225225
"http:",
226226
"https:",
227227
]);
228-
assert.deepEqual(directives["media-src"], ["'self'", "pylon-code:", "blob:"]);
228+
assert.deepEqual(directives["media-src"], [
229+
"'self'",
230+
"pylon-code:",
231+
"blob:",
232+
"http:",
233+
"https:",
234+
]);
229235
assert.deepEqual(directives["font-src"], ["'self'", "pylon-code:", "data:"]);
230236
});
231237
});

apps/desktop/src/electron/ElectronProtocol.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrat
8787
`script-src ${scriptSources.join(" ")}`,
8888
`connect-src ${connectSources.join(" ")}`,
8989
`img-src 'self' ${input.scheme}: blob: data: http: https:`,
90-
`media-src 'self' ${input.scheme}: blob:`,
90+
`media-src 'self' ${input.scheme}: blob: http: https:`,
9191
"style-src 'self' 'unsafe-inline'",
9292
`font-src 'self' ${input.scheme}: data:`,
9393
"worker-src 'self' blob:",
@@ -118,6 +118,7 @@ export function registerDesktopSchemePrivilegesSync(): void {
118118
secure: true,
119119
supportFetchAPI: true,
120120
corsEnabled: true,
121+
stream: true,
121122
},
122123
},
123124
{
@@ -127,6 +128,7 @@ export function registerDesktopSchemePrivilegesSync(): void {
127128
secure: true,
128129
supportFetchAPI: true,
129130
corsEnabled: true,
131+
stream: true,
130132
},
131133
},
132134
]);
676 Bytes
Loading

apps/mobile/modules/t3-markdown-text/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
"./types": "./src/SelectableMarkdownText.types.ts"
2727
},
2828
"peerDependencies": {
29+
"@t3tools/client-runtime": "*",
30+
"@t3tools/shared": "*",
2931
"expo-asset": "*",
3032
"expo-clipboard": "*",
3133
"expo-haptics": "*",

apps/mobile/modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ const colors = {
6666
terraform: "#693acf",
6767
text: "#84848a",
6868
typescript: "#1a85d4",
69+
video: "#a631be",
6970
vite: "#a631be",
7071
vscode: "#1a85d4",
7172
vue: "#199f43",
@@ -83,6 +84,7 @@ const customIcons = {
8384
pnpm: "t3-file-icon-pnpm",
8485
readme: "t3-file-icon-readme",
8586
tsconfig: "t3-file-icon-tsconfig",
87+
video: "t3-file-icon-video",
8688
};
8789

8890
function symbolFromSprite(sprite, id) {

apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.generated.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export const MARKDOWN_FILE_ICON_SOURCES = {
5151
text: require("../assets/file-icons/pierre_text.png"),
5252
tsconfig: require("../assets/file-icons/pierre_tsconfig.png"),
5353
typescript: require("../assets/file-icons/pierre_typescript.png"),
54+
video: require("../assets/file-icons/pierre_video.png"),
5455
vite: require("../assets/file-icons/pierre_vite.png"),
5556
vscode: require("../assets/file-icons/pierre_vscode.png"),
5657
vue: require("../assets/file-icons/pierre_vue.png"),

apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
import {
2+
inlineCodeFilePathCandidate,
3+
isConventionalFilePosition,
4+
} from "@t3tools/client-runtime/markdown-links";
5+
import { videoMimeType } from "@t3tools/shared/video";
6+
17
import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated";
28

39
const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
@@ -253,15 +259,22 @@ function normalizeDestination(value: string): string {
253259
return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed;
254260
}
255261

262+
/** Native link and media APIs have no document scheme to inherit from protocol-relative URLs. */
263+
export function normalizeNativeMarkdownUrl(value: string): string {
264+
return value.startsWith("//") ? `https:${value}` : value;
265+
}
266+
256267
function fileUrlTarget(href: string): { readonly path: string; readonly hash: string } | null {
257268
try {
258269
const parsed = new URL(href);
259270
if (parsed.protocol.toLowerCase() !== "file:") {
260271
return null;
261272
}
262-
const path = /^\/[A-Za-z]:[\\/]/.test(parsed.pathname)
263-
? parsed.pathname.slice(1)
273+
const uncHostname = parsed.hostname.toLowerCase() === "localhost" ? "" : parsed.hostname;
274+
const rawPath = uncHostname
275+
? `\\\\${uncHostname}${parsed.pathname.replaceAll("/", "\\")}`
264276
: parsed.pathname;
277+
const path = /^\/[A-Za-z]:[\\/]/.test(rawPath) ? rawPath.slice(1) : rawPath;
265278
return { path, hash: parsed.hash };
266279
} catch {
267280
return null;
@@ -327,6 +340,7 @@ function looksLikeFilePath(value: string): boolean {
327340
if (FILE_ICON_BY_NAME[value.replace(POSITION_SUFFIX_PATTERN, "").toLowerCase()]) {
328341
return true;
329342
}
343+
if (isConventionalFilePosition(value)) return true;
330344
return RELATIVE_FILE_PATH_PATTERN.test(value) || RELATIVE_FILE_NAME_PATTERN.test(value);
331345
}
332346

@@ -338,6 +352,7 @@ function fileLabel(value: string): string {
338352

339353
export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon {
340354
const basename = fileLabel(value).replace(POSITION_SUFFIX_PATTERN, "").toLowerCase();
355+
if (videoMimeType({ name: basename, mimeType: "" }) !== null) return "video";
341356
const exactIcon = FILE_ICON_BY_NAME[basename];
342357
if (exactIcon) return exactIcon;
343358
if (basename.startsWith("tsconfig.") && basename.endsWith(".json")) {
@@ -354,7 +369,7 @@ export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon {
354369
export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPresentation {
355370
const normalized = normalizeDestination(href);
356371
try {
357-
const parsed = new URL(normalized);
372+
const parsed = new URL(normalizeNativeMarkdownUrl(normalized));
358373
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
359374
return {
360375
kind: "external",
@@ -399,3 +414,13 @@ export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPrese
399414
href: /^(?:mailto|tel):/i.test(normalized) ? normalized : null,
400415
};
401416
}
417+
418+
/** Backticks become file references only when the shared path heuristic recognizes the whole span. */
419+
export function resolveMarkdownInlineCodePresentation(
420+
content: string,
421+
): Extract<MarkdownLinkPresentation, { readonly kind: "file" }> | null {
422+
const candidate = inlineCodeFilePathCandidate(content);
423+
if (candidate === null) return null;
424+
const presentation = resolveMarkdownLinkPresentation(candidate);
425+
return presentation.kind === "file" ? presentation : null;
426+
}

apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import type { MarkdownNode } from "react-native-nitro-markdown/headless";
22

33
import type { SelectableMarkdownSkill } from "./SelectableMarkdownText.types";
4-
import { resolveMarkdownLinkPresentation, type MarkdownFileIcon } from "./markdownLinks";
4+
import {
5+
resolveMarkdownInlineCodePresentation,
6+
resolveMarkdownLinkPresentation,
7+
type MarkdownFileIcon,
8+
} from "./markdownLinks";
59

610
export interface NativeMarkdownTextRun {
711
readonly text: string;
@@ -283,8 +287,17 @@ function appendNode(
283287
return appendRun(runs, textNodeContent(nodeTextContent(node)), context);
284288
case "html_inline":
285289
return appendRun(runs, inlineHtmlText(nodeTextContent(node)), context);
286-
case "code_inline":
287-
return appendRun(runs, nodeTextContent(node), { ...context, code: true });
290+
case "code_inline": {
291+
const content = nodeTextContent(node);
292+
const presentation = context.href ? null : resolveMarkdownInlineCodePresentation(content);
293+
return presentation
294+
? appendRun(runs, presentation.label, {
295+
...context,
296+
href: presentation.href,
297+
fileIcon: presentation.icon,
298+
})
299+
: appendRun(runs, content, { ...context, code: true });
300+
}
288301
case "soft_break":
289302
return appendRun(runs, " ", context);
290303
case "line_break":

apps/mobile/src/components/FilePreview.ios.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useEffect, useEffectEvent, useId } from "react";
33
import { Alert } from "react-native";
44

55
import type { ResolvedFilePreviewSource } from "./FilePreviewModal";
6+
import { MediaImagePreview } from "./MediaImagePreview";
67

78
const NativeControls = requireNativeModule<{
89
presentFile(
@@ -14,7 +15,7 @@ const NativeControls = requireNativeModule<{
1415
dismissFile(identifier: string): Promise<void>;
1516
}>("T3NativeControls");
1617

17-
export function FilePreview(props: {
18+
function NativeFilePreview(props: {
1819
readonly source: ResolvedFilePreviewSource;
1920
readonly onRequestClose: () => void;
2021
}) {
@@ -41,3 +42,14 @@ export function FilePreview(props: {
4142

4243
return null;
4344
}
45+
46+
export function FilePreview(props: {
47+
readonly source: ResolvedFilePreviewSource;
48+
readonly onRequestClose: () => void;
49+
}) {
50+
return props.source.kind === "image" && props.source.actionsSource ? (
51+
<MediaImagePreview {...props} />
52+
) : (
53+
<NativeFilePreview {...props} />
54+
);
55+
}

apps/mobile/src/components/FilePreview.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import ImageViewing from "react-native-image-viewing";
44

55
import { downloadAndShareAttachment, shareLocalAttachment } from "../lib/attachmentDownload";
66
import type { ResolvedFilePreviewSource } from "./FilePreviewModal";
7+
import { MediaImagePreview } from "./MediaImagePreview";
78

89
function PdfPreview(props: {
910
readonly source: ResolvedFilePreviewSource;
@@ -39,6 +40,7 @@ export function FilePreview(props: {
3940
readonly onRequestClose: () => void;
4041
}) {
4142
if (props.source.kind === "pdf") return <PdfPreview {...props} />;
43+
if (props.source.actionsSource) return <MediaImagePreview {...props} />;
4244
return (
4345
<ImageViewing
4446
images={[{ uri: props.source.uri }]}

0 commit comments

Comments
 (0)