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
8 changes: 7 additions & 1 deletion apps/desktop/src/electron/ElectronProtocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,13 @@ describe("ElectronProtocol", () => {
"http:",
"https:",
]);
assert.deepEqual(directives["media-src"], ["'self'", "pylon-code:", "blob:"]);
assert.deepEqual(directives["media-src"], [
"'self'",
"pylon-code:",
"blob:",
"http:",
"https:",
]);
assert.deepEqual(directives["font-src"], ["'self'", "pylon-code:", "data:"]);
});
});
4 changes: 3 additions & 1 deletion apps/desktop/src/electron/ElectronProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrat
`script-src ${scriptSources.join(" ")}`,
`connect-src ${connectSources.join(" ")}`,
`img-src 'self' ${input.scheme}: blob: data: http: https:`,
`media-src 'self' ${input.scheme}: blob:`,
`media-src 'self' ${input.scheme}: blob: http: https:`,
"style-src 'self' 'unsafe-inline'",
`font-src 'self' ${input.scheme}: data:`,
"worker-src 'self' blob:",
Expand Down Expand Up @@ -118,6 +118,7 @@ export function registerDesktopSchemePrivilegesSync(): void {
secure: true,
supportFetchAPI: true,
corsEnabled: true,
stream: true,
},
},
{
Expand All @@ -127,6 +128,7 @@ export function registerDesktopSchemePrivilegesSync(): void {
secure: true,
supportFetchAPI: true,
corsEnabled: true,
stream: true,
},
},
]);
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions apps/mobile/modules/t3-markdown-text/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
"./types": "./src/SelectableMarkdownText.types.ts"
},
"peerDependencies": {
"@t3tools/client-runtime": "*",
"@t3tools/shared": "*",
"expo-asset": "*",
"expo-clipboard": "*",
"expo-haptics": "*",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ const colors = {
terraform: "#693acf",
text: "#84848a",
typescript: "#1a85d4",
video: "#a631be",
vite: "#a631be",
vscode: "#1a85d4",
vue: "#199f43",
Expand All @@ -83,6 +84,7 @@ const customIcons = {
pnpm: "t3-file-icon-pnpm",
readme: "t3-file-icon-readme",
tsconfig: "t3-file-icon-tsconfig",
video: "t3-file-icon-video",
};

function symbolFromSprite(sprite, id) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export const MARKDOWN_FILE_ICON_SOURCES = {
text: require("../assets/file-icons/pierre_text.png"),
tsconfig: require("../assets/file-icons/pierre_tsconfig.png"),
typescript: require("../assets/file-icons/pierre_typescript.png"),
video: require("../assets/file-icons/pierre_video.png"),
vite: require("../assets/file-icons/pierre_vite.png"),
vscode: require("../assets/file-icons/pierre_vscode.png"),
vue: require("../assets/file-icons/pierre_vue.png"),
Expand Down
31 changes: 28 additions & 3 deletions apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
import {
inlineCodeFilePathCandidate,
isConventionalFilePosition,
} from "@t3tools/client-runtime/markdown-links";
import { videoMimeType } from "@t3tools/shared/video";

import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated";

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

/** Native link and media APIs have no document scheme to inherit from protocol-relative URLs. */
export function normalizeNativeMarkdownUrl(value: string): string {
return value.startsWith("//") ? `https:${value}` : value;
}

function fileUrlTarget(href: string): { readonly path: string; readonly hash: string } | null {
try {
const parsed = new URL(href);
if (parsed.protocol.toLowerCase() !== "file:") {
return null;
}
const path = /^\/[A-Za-z]:[\\/]/.test(parsed.pathname)
? parsed.pathname.slice(1)
const uncHostname = parsed.hostname.toLowerCase() === "localhost" ? "" : parsed.hostname;
const rawPath = uncHostname
? `\\\\${uncHostname}${parsed.pathname.replaceAll("/", "\\")}`
: parsed.pathname;
const path = /^\/[A-Za-z]:[\\/]/.test(rawPath) ? rawPath.slice(1) : rawPath;
return { path, hash: parsed.hash };
} catch {
return null;
Expand Down Expand Up @@ -327,6 +340,7 @@ function looksLikeFilePath(value: string): boolean {
if (FILE_ICON_BY_NAME[value.replace(POSITION_SUFFIX_PATTERN, "").toLowerCase()]) {
return true;
}
if (isConventionalFilePosition(value)) return true;
return RELATIVE_FILE_PATH_PATTERN.test(value) || RELATIVE_FILE_NAME_PATTERN.test(value);
}

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

export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon {
const basename = fileLabel(value).replace(POSITION_SUFFIX_PATTERN, "").toLowerCase();
if (videoMimeType({ name: basename, mimeType: "" }) !== null) return "video";
const exactIcon = FILE_ICON_BY_NAME[basename];
if (exactIcon) return exactIcon;
if (basename.startsWith("tsconfig.") && basename.endsWith(".json")) {
Expand All @@ -354,7 +369,7 @@ export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon {
export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPresentation {
const normalized = normalizeDestination(href);
try {
const parsed = new URL(normalized);
const parsed = new URL(normalizeNativeMarkdownUrl(normalized));
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
return {
kind: "external",
Expand Down Expand Up @@ -399,3 +414,13 @@ export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPrese
href: /^(?:mailto|tel):/i.test(normalized) ? normalized : null,
};
}

/** Backticks become file references only when the shared path heuristic recognizes the whole span. */
export function resolveMarkdownInlineCodePresentation(
content: string,
): Extract<MarkdownLinkPresentation, { readonly kind: "file" }> | null {
const candidate = inlineCodeFilePathCandidate(content);
if (candidate === null) return null;
const presentation = resolveMarkdownLinkPresentation(candidate);
return presentation.kind === "file" ? presentation : null;
}
19 changes: 16 additions & 3 deletions apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import type { MarkdownNode } from "react-native-nitro-markdown/headless";

import type { SelectableMarkdownSkill } from "./SelectableMarkdownText.types";
import { resolveMarkdownLinkPresentation, type MarkdownFileIcon } from "./markdownLinks";
import {
resolveMarkdownInlineCodePresentation,
resolveMarkdownLinkPresentation,
type MarkdownFileIcon,
} from "./markdownLinks";

export interface NativeMarkdownTextRun {
readonly text: string;
Expand Down Expand Up @@ -283,8 +287,17 @@ function appendNode(
return appendRun(runs, textNodeContent(nodeTextContent(node)), context);
case "html_inline":
return appendRun(runs, inlineHtmlText(nodeTextContent(node)), context);
case "code_inline":
return appendRun(runs, nodeTextContent(node), { ...context, code: true });
case "code_inline": {
const content = nodeTextContent(node);
const presentation = context.href ? null : resolveMarkdownInlineCodePresentation(content);
return presentation
? appendRun(runs, presentation.label, {
...context,
href: presentation.href,
fileIcon: presentation.icon,
})
: appendRun(runs, content, { ...context, code: true });
}
case "soft_break":
return appendRun(runs, " ", context);
case "line_break":
Expand Down
14 changes: 13 additions & 1 deletion apps/mobile/src/components/FilePreview.ios.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useEffect, useEffectEvent, useId } from "react";
import { Alert } from "react-native";

import type { ResolvedFilePreviewSource } from "./FilePreviewModal";
import { MediaImagePreview } from "./MediaImagePreview";

const NativeControls = requireNativeModule<{
presentFile(
Expand All @@ -14,7 +15,7 @@ const NativeControls = requireNativeModule<{
dismissFile(identifier: string): Promise<void>;
}>("T3NativeControls");

export function FilePreview(props: {
function NativeFilePreview(props: {
readonly source: ResolvedFilePreviewSource;
readonly onRequestClose: () => void;
}) {
Expand All @@ -41,3 +42,14 @@ export function FilePreview(props: {

return null;
}

export function FilePreview(props: {
readonly source: ResolvedFilePreviewSource;
readonly onRequestClose: () => void;
}) {
return props.source.kind === "image" && props.source.actionsSource ? (
<MediaImagePreview {...props} />
) : (
<NativeFilePreview {...props} />
);
}
2 changes: 2 additions & 0 deletions apps/mobile/src/components/FilePreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import ImageViewing from "react-native-image-viewing";

import { downloadAndShareAttachment, shareLocalAttachment } from "../lib/attachmentDownload";
import type { ResolvedFilePreviewSource } from "./FilePreviewModal";
import { MediaImagePreview } from "./MediaImagePreview";

function PdfPreview(props: {
readonly source: ResolvedFilePreviewSource;
Expand Down Expand Up @@ -39,6 +40,7 @@ export function FilePreview(props: {
readonly onRequestClose: () => void;
}) {
if (props.source.kind === "pdf") return <PdfPreview {...props} />;
if (props.source.actionsSource) return <MediaImagePreview {...props} />;
return (
<ImageViewing
images={[{ uri: props.source.uri }]}
Expand Down
16 changes: 12 additions & 4 deletions apps/mobile/src/components/FilePreviewModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Alert, Keyboard } from "react-native";

import type { DraftComposerFileAttachment } from "../lib/composerImages";
import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
import type { MediaActionsSource } from "../lib/mediaActions";
import { useAssetUrlState } from "../state/assets";
import { usePreparedConnection } from "../state/session";
import { FilePreview } from "./FilePreview";
Expand All @@ -14,6 +15,8 @@ export interface ResolvedFilePreviewSource {
readonly uri: string;
readonly name?: string;
readonly sourceIdentifier?: string;
readonly srcFragment?: string;
readonly actionsSource?: MediaActionsSource;
}

export type FilePreviewSource = Omit<ResolvedFilePreviewSource, "uri"> &
Expand All @@ -40,13 +43,18 @@ function ResolvedFilePreview(props: {
(connection._tag === "None" || asset._tag === "Failure");
useEffect(() => Keyboard.dismiss(), []);
useEffect(() => {
if (uri === null && asset._tag === "Success") setUri(asset.url);
}, [uri, asset]);
if (uri === null && asset._tag === "Success") setUri(asset.url + (source.srcFragment ?? ""));
}, [uri, asset, source.srcFragment]);
useEffect(() => {
if (!failed) return;
Alert.alert("Could not open preview", "Reconnect to this environment and try again.");
Alert.alert(
"Could not open preview",
connection._tag === "None"
? "Reconnect to this environment and try again."
: "The file could not be loaded. It may have been moved or deleted.",
);
onRequestClose();
}, [failed]);
}, [failed, connection._tag]);
useEffect(() => {
if (!("attachment" in source)) return;
const controller = new AbortController();
Expand Down
43 changes: 43 additions & 0 deletions apps/mobile/src/components/MediaActionsMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { MenuView } from "@react-native-menu/menu";
import type { ReactElement } from "react";
import { Platform, View, type PressableProps } from "react-native";

import type { useMediaActions } from "../lib/mediaActions";
import { SymbolView } from "./AppSymbol";
import { ControlPillMenu } from "./ControlPill";

export function MediaActionsMenu(props: {
readonly media: ReturnType<typeof useMediaActions>;
readonly inModal?: boolean;
readonly children?: ReactElement<PressableProps>;
}) {
if (props.media.actions.length === 0) return props.children ?? null;
// Android's normal anchored menu lives in the app-root portal, behind native modals.
const nativeAndroidMenu = props.inModal && Platform.OS === "android";
const Menu = nativeAndroidMenu ? MenuView : ControlPillMenu;
return (
<Menu
title={props.media.title}
shouldOpenOnLongPress={props.children !== undefined}
actions={props.media.actions.map(({ id, title, disabled }) => ({
id,
title,
attributes: { disabled: disabled ?? false },
}))}
onPressAction={({ nativeEvent }) => {
props.media.actions.find(({ id }) => id === nativeEvent.event)?.run();
}}
>
{props.children ?? (
<View
accessible
accessibilityRole="button"
accessibilityLabel="Media actions"
className="min-h-11 min-w-11 items-center justify-center rounded-md bg-black/60"
>
<SymbolView name="ellipsis" size={20} tintColor="#ffffff" type="monochrome" />
</View>
)}
</Menu>
);
}
61 changes: 61 additions & 0 deletions apps/mobile/src/components/MediaImagePreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { createContext, useContext } from "react";
import { Pressable, View } from "react-native";
import ImageViewing from "react-native-image-viewing";
import { useSafeAreaInsets } from "react-native-safe-area-context";

import { useMediaActions } from "../lib/mediaActions";
import { AppText } from "./AppText";
import { SymbolView } from "./AppSymbol";
import type { ResolvedFilePreviewSource } from "./FilePreviewModal";
import { MediaActionsMenu } from "./MediaActionsMenu";
import { MediaSourceCaption } from "./MediaSourceCaption";

type MediaImagePreviewProps = {
readonly source: ResolvedFilePreviewSource;
readonly onRequestClose: () => void;
};

const ImagePreviewContext = createContext<MediaImagePreviewProps | null>(null);

function ImagePreviewHeader() {
const props = useContext(ImagePreviewContext)!;
const insets = useSafeAreaInsets();
const mediaActions = useMediaActions(props.source.actionsSource, props.onRequestClose);
return (
<View className="bg-black/70" style={{ paddingTop: insets.top }}>
<View className="flex-row items-center gap-2 px-3">
<AppText className="flex-1 text-base text-white" numberOfLines={2}>
{props.source.name ?? "Image"}
</AppText>
<MediaActionsMenu media={mediaActions} inModal />
<Pressable
accessibilityRole="button"
accessibilityLabel="Close image"
onPress={props.onRequestClose}
className="min-h-11 min-w-11 items-center justify-center"
>
<SymbolView name="xmark" size={20} tintColor="#ffffff" type="monochrome" />
</Pressable>
</View>
<MediaSourceCaption source={mediaActions.title} />
</View>
);
}

/** Chat and workspace media retain source actions on both platforms; other files use native previews. */
export function MediaImagePreview(props: MediaImagePreviewProps) {
return (
<ImagePreviewContext value={props}>
<ImageViewing
images={[{ uri: props.source.uri }]}
imageIndex={0}
visible
presentationStyle="fullScreen"
onRequestClose={props.onRequestClose}
swipeToCloseEnabled
doubleTapToZoomEnabled
HeaderComponent={ImagePreviewHeader}
/>
</ImagePreviewContext>
);
}
19 changes: 19 additions & 0 deletions apps/mobile/src/components/MediaSourceCaption.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { ScrollView } from "react-native";

import { AppText } from "./AppText";

/** Keep the original reference readable without letting long URLs displace the preview. */
export function MediaSourceCaption(props: { readonly source: string | undefined }) {
if (!props.source) return null;
return (
<ScrollView
style={{ maxHeight: 88, flexGrow: 0 }}
contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 8 }}
nestedScrollEnabled
>
<AppText selectable className="text-xs text-white/70">
{props.source}
</AppText>
</ScrollView>
);
}
Loading
Loading