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
26 changes: 7 additions & 19 deletions apps/mobile/src/features/threads/CopyTextButton.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import * as Dialog from "../../components/ui/Dialog";
import { SymbolView } from "../../components/ui/AppSymbol";
import { memo, useEffect, useRef, useState } from "react";
import { memo, useState } from "react";
import { Pressable, type ColorValue } from "react-native";

import { useDeadlinePassed } from "../../lib/clock";
import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic";

const COPY_FEEDBACK_DURATION_MS = 1200;
Expand All @@ -20,17 +21,11 @@ export const CopyTextButton = memo(function CopyTextButton(props: {
readonly iconSize?: number;
readonly buttonSize?: number;
}) {
const [copied, setCopied] = useState(false);
const resetTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

useEffect(
() => () => {
if (resetTimeoutRef.current) {
clearTimeout(resetTimeoutRef.current);
}
},
[],
const [copiedAtMs, setCopiedAtMs] = useState<number | null>(null);
const feedbackEnded = useDeadlinePassed(
copiedAtMs === null ? null : copiedAtMs + COPY_FEEDBACK_DURATION_MS,
);
const copied = copiedAtMs !== null && !feedbackEnded;

return (
<Pressable
Expand All @@ -50,14 +45,7 @@ export const CopyTextButton = memo(function CopyTextButton(props: {
Dialog.alert("Could not copy", "Try again.");
return;
}
setCopied(true);
if (resetTimeoutRef.current) {
clearTimeout(resetTimeoutRef.current);
}
resetTimeoutRef.current = setTimeout(() => {
setCopied(false);
resetTimeoutRef.current = null;
}, COPY_FEEDBACK_DURATION_MS);
setCopiedAtMs(Date.now());
}}
style={({ pressed }) => ({
width: props.buttonSize ?? 30,
Expand Down
54 changes: 54 additions & 0 deletions apps/mobile/src/features/threads/ThreadMarkdownImage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ vi.mock("../../components/ui/NativePresentation", () => ({
vi.mock("../../state/mediaActions", () => ({ useMediaActions: () => ({ actions: [] }) }));
vi.mock("../../state/assets", () => ({ useAssetUrlState: vi.fn() }));

import { RegistryContext } from "@effect/atom-react";
import { AtomRegistry } from "effect/reactivity";
import { fetch } from "expo/fetch";
import { parse } from "react-native-svg";

import { ThreadMarkdownImageView } from "./ThreadMarkdownImage";

it("retries a failed image once its URI comes back through null", async () => {
Expand Down Expand Up @@ -64,3 +69,52 @@ it("retries a failed image once its URI comes back through null", async () => {
await act(async () => root.unmount());
}
});

it("fetches a failed SVG again on the next mount but keeps a parsed one", async () => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
// Microtask scheduling lets act() flush an unmounted atom's removal before the next render.
const registry = AtomRegistry.make({
scheduleTask: (task) => {
queueMicrotask(task);
return () => {};
},
});
const fetchSvg = vi.mocked(fetch);
vi.mocked(parse).mockReturnValue({ props: {} } as ReturnType<typeof parse>);
const container = document.createElement("div");
const root = createRoot(container);
const uri = "https://example.test/a.svg";
const view = (mounted: boolean) => (
<RegistryContext.Provider value={registry}>
{mounted ? (
<ThreadMarkdownImageView
uri={uri}
sourceKey="attachment:svg"
unavailable={false}
alt={null}
format="svg"
onPressPreview={() => {}}
/>
) : null}
</RegistryContext.Provider>
);
try {
fetchSvg.mockRejectedValueOnce(new Error("offline"));
await act(async () => root.render(view(true)));
await vi.waitFor(() => expect(container.textContent).toContain("Image unavailable"));

await act(async () => root.render(view(false)));
fetchSvg.mockResolvedValue({ ok: true, status: 200, text: async () => "<svg />" } as never);
await act(async () => root.render(view(true)));
await vi.waitFor(() => expect(container.textContent).not.toContain("Image unavailable"));
expect(fetchSvg).toHaveBeenCalledTimes(2);

await act(async () => root.render(view(false)));
await act(async () => root.render(view(true)));
expect(container.textContent).not.toContain("Image unavailable");
expect(fetchSvg).toHaveBeenCalledTimes(2);
} finally {
await act(async () => root.unmount());
registry.dispose();
}
});
195 changes: 108 additions & 87 deletions apps/mobile/src/features/threads/ThreadMarkdownImage.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { useAtomValue } from "@effect/atom-react";
import { MISSING_FILE_ACCESS_MESSAGE } from "@t3tools/client-runtime/state/assets";
import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
import * as Data from "effect/Data";
import * as Effect from "effect/Effect";
import { AsyncResult, Atom } from "effect/reactivity";
import { Image } from "expo-image";
import { createContext, useContext, useEffect, useEffectEvent, useId, useState } from "react";
import { createContext, useContext, useId, useState } from "react";
import { fetch } from "expo/fetch";
import { parse, SvgAst, type JsxAST } from "react-native-svg";
import { Pressable, StyleSheet, View, type ViewStyle } from "react-native";
Expand All @@ -25,6 +29,58 @@ import {
/** Use known feed width immediately; later image measurement would move already-positioned rows. */
export const MarkdownImageAvailableWidthContext = createContext(0);

type ImageSize = { readonly width: number; readonly height: number };

class SvgLoadError extends Data.TaggedError("SvgLoadError")<{ readonly cause: unknown }> {}

async function readSvgXml(uri: string, signal: AbortSignal): Promise<string> {
if (uri.startsWith("data:image/svg+xml")) {
const comma = uri.indexOf(",");
if (comma < 0) throw new Error("SVG data URI has no content");
const content = decodeURIComponent(uri.slice(comma + 1));
return uri.slice(0, comma).includes(";base64")
? new TextDecoder().decode(
Uint8Array.from(atob(content), (character) => character.charCodeAt(0)),
)
: content;
}
// expo/fetch, not the runtime HttpClient: it is the one that reads file:// URIs.
const response = await fetch(uri, { signal });
if (!response.ok && !(response.status === 0 && uri.startsWith("file://")))
throw new Error(`SVG request failed: ${response.status}`);
return response.text();
}

type SvgDocument = { readonly ast: JsxAST; readonly size: ImageSize | null };

// Feed rows unmount as they scroll away. Only parsed documents outlive their
// readers (for the row that scrolls back); a failed load is dropped with its last
// reader so the next mount fetches again.
const parsedSvgDocumentAtom = Atom.family((_uri: string) =>
Atom.make<SvgDocument | null>(null).pipe(
Atom.setIdleTTL(60_000),
Atom.withLabel("mobile:thread-markdown-svg-parsed"),
),
);

const svgDocumentAtom = Atom.family((uri: string) =>
Atom.make((get) => {
const parsed = parsedSvgDocumentAtom(uri);
const cached = get(parsed);
if (cached !== null) return Effect.succeed(cached);
return Effect.tryPromise({
try: async (signal): Promise<SvgDocument> => {
const ast = parse(await readSvgXml(uri, signal));
if (!ast) throw new Error("SVG has no root element");
return { ast, size: resolveSvgImageSize(ast.props) };
},
catch: (cause) => new SvgLoadError({ cause }),
}).pipe(Effect.tap((document) => Effect.sync(() => get.set(parsed, document))));
}).pipe(Atom.withLabel("mobile:thread-markdown-svg")),
);

const noSvgDocumentAtom = Atom.make(AsyncResult.initial<SvgDocument, SvgLoadError>());

export function ThreadMarkdownImageView(props: {
readonly uri: string | null;
readonly sourceKey: string;
Expand All @@ -45,12 +101,14 @@ export function ThreadMarkdownImageView(props: {
measuredWidth > 0 && contextWidth > 0
? Math.min(contextWidth, measuredWidth)
: contextWidth || measuredWidth;
const svg = useAtomValue(
props.format === "svg" && props.uri !== null ? svgDocumentAtom(props.uri) : noSvgDocumentAtom,
);
const svgDocument = AsyncResult.isSuccess(svg) ? svg.value : null;
// Keyed by source so a new source drops the previous image's decoded dimensions.
const [decoded, setDecoded] = useState<{
sourceKey: string;
size: { width: number; height: number };
} | null>(null);
const decodedSize = decoded?.sourceKey === props.sourceKey ? decoded.size : null;
const [decoded, setDecoded] = useState<{ sourceKey: string; size: ImageSize } | null>(null);
const decodedSize =
svgDocument?.size ?? (decoded?.sourceKey === props.sourceKey ? decoded.size : null);
// A failure belongs to one URI; any URI change, including through null, retries.
const [failure, setFailure] = useState({ uri: props.uri, failed: false });
if (failure.uri !== props.uri) setFailure({ uri: props.uri, failed: false });
Expand All @@ -65,7 +123,9 @@ export function ThreadMarkdownImageView(props: {
availableWidth,
});
const failed =
props.unavailable || (props.uri !== null && failure.uri === props.uri && failure.failed);
props.unavailable ||
(props.uri !== null && failure.uri === props.uri && failure.failed) ||
AsyncResult.isFailure(svg);
const placeholderWidth: ViewStyle["width"] =
availableWidth > 0 ? Math.min(availableWidth, MARKDOWN_IMAGE_MAX_WIDTH) : "100%";
const frameStyle: ViewStyle = displaySize ?? { width: placeholderWidth, aspectRatio: 16 / 9 };
Expand Down Expand Up @@ -123,13 +183,16 @@ export function ThreadMarkdownImageView(props: {
className="items-center justify-center overflow-hidden rounded-[10px] bg-md-code-bg"
style={frameStyle}
>
<ThreadMarkdownImageRequest
key={props.sourceKey}
uri={props.uri}
format={props.format}
onLoad={(size) => setDecoded({ sourceKey: props.sourceKey, size })}
onError={() => setFailure({ uri: props.uri, failed: true })}
/>
{props.format === "svg" ? (
<ThreadMarkdownSvg ast={svgDocument?.ast ?? null} />
) : (
<ThreadMarkdownImageRequest
key={props.sourceKey}
uri={props.uri}
onLoad={(size) => setDecoded({ sourceKey: props.sourceKey, size })}
onError={() => setFailure({ uri: props.uri, failed: true })}
/>
)}
</View>
</Pressable>
</MediaActionsMenu>
Expand All @@ -146,89 +209,47 @@ export function ThreadMarkdownImageView(props: {

function ThreadMarkdownImageRequest(props: {
readonly uri: string;
readonly onLoad: (sourceSize: { width: number; height: number }) => void;
readonly onLoad: (sourceSize: ImageSize) => void;
readonly onError: () => void;
readonly format?: "svg";
}) {
const [loaded, setLoaded] = useState(false);

return (
<>
{props.format === "svg" ? (
<ThreadMarkdownSvg
uri={props.uri}
onLoad={(size) => {
setLoaded(true);
if (size) props.onLoad(size);
}}
onError={props.onError}
/>
) : (
<Image
source={{ uri: props.uri }}
contentFit="contain"
cachePolicy="memory-disk"
accessible={false}
onLoad={(event) => {
setLoaded(true);
props.onLoad(event.source);
}}
onError={props.onError}
style={{ width: "100%", height: "100%", opacity: loaded ? 1 : 0 }}
/>
)}
{loaded ? null : (
<View
pointerEvents="none"
style={[StyleSheet.absoluteFill, { alignItems: "center", justifyContent: "center" }]}
>
<Text className="text-xs text-foreground-muted">Loading image…</Text>
</View>
)}
<Image
source={{ uri: props.uri }}
contentFit="contain"
cachePolicy="memory-disk"
accessible={false}
onLoad={(event) => {
setLoaded(true);
props.onLoad(event.source);
}}
onError={props.onError}
style={{ width: "100%", height: "100%", opacity: loaded ? 1 : 0 }}
/>
{loaded ? null : <ThreadMarkdownImageLoading />}
</>
);
}

function ThreadMarkdownSvg(props: {
readonly uri: string;
readonly onLoad: (size: { width: number; height: number } | null) => void;
readonly onError: () => void;
}) {
const [ast, setAst] = useState<JsxAST | null>(null);
const onLoad = useEffectEvent(props.onLoad);
const onError = useEffectEvent(props.onError);
useEffect(() => {
const controller = new AbortController();
const load = async () => {
if (props.uri.startsWith("data:image/svg+xml")) {
const comma = props.uri.indexOf(",");
if (comma < 0) throw new Error("SVG data URI has no content");
const content = decodeURIComponent(props.uri.slice(comma + 1));
return props.uri.slice(0, comma).includes(";base64")
? new TextDecoder().decode(
Uint8Array.from(atob(content), (character) => character.charCodeAt(0)),
)
: content;
}
const response = await fetch(props.uri, { signal: controller.signal });
if (!response.ok && !(response.status === 0 && props.uri.startsWith("file://")))
throw new Error(`SVG request failed: ${response.status}`);
return response.text();
};
void load()
.then((xml) => {
const parsed = parse(xml);
if (!parsed) throw new Error("SVG has no root element");
if (controller.signal.aborted) return;
setAst(parsed);
onLoad(resolveSvgImageSize(parsed.props));
})
.catch(() => {
if (!controller.signal.aborted) onError();
});
return () => controller.abort();
}, [props.uri]);
return <SvgAst ast={ast} override={{ width: "100%", height: "100%", accessible: false }} />;
function ThreadMarkdownImageLoading() {
return (
<View
pointerEvents="none"
style={[StyleSheet.absoluteFill, { alignItems: "center", justifyContent: "center" }]}
>
<Text className="text-xs text-foreground-muted">Loading image…</Text>
</View>
);
}

function ThreadMarkdownSvg(props: { readonly ast: JsxAST | null }) {
return props.ast === null ? (
<ThreadMarkdownImageLoading />
) : (
<SvgAst ast={props.ast} override={{ width: "100%", height: "100%", accessible: false }} />
);
}

export function ThreadMarkdownImage(props: {
Expand Down
Loading
Loading