diff --git a/apps/mobile/src/components/FullScreenImageViewer.tsx b/apps/mobile/src/components/FullScreenImageViewer.tsx new file mode 100644 index 00000000000..ebc7cd5b036 --- /dev/null +++ b/apps/mobile/src/components/FullScreenImageViewer.tsx @@ -0,0 +1,41 @@ +import { useCallback, useMemo } from "react"; +import ImageViewing from "react-native-image-viewing"; + +import type { FullScreenImageSource } from "../lib/fullScreenImageActions"; +import { useShareImage } from "../lib/useShareImage"; + +/** + * Fullscreen image viewer. Long-pressing the image opens the system share + * sheet, which is where both platforms already put Copy and Save Image, so + * this owns no chrome of its own. + */ +export function FullScreenImageViewer(props: { + readonly source: FullScreenImageSource | null; + readonly onRequestClose: () => void; +}) { + const { source } = props; + const share = useShareImage(); + + const images = useMemo( + () => (source === null ? [] : [{ uri: source.uri, cache: source.cache }]), + [source], + ); + + const onLongPress = useCallback(() => { + if (source !== null) { + share(source); + } + }, [share, source]); + + return ( + + ); +} diff --git a/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx b/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx index 73eca66bf99..4a0d4bbf757 100644 --- a/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx +++ b/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx @@ -1,11 +1,12 @@ import { useAtomValue } from "@effect/atom-react"; import { useMemo, useState } from "react"; import { ActivityIndicator, Image, Pressable, View } from "react-native"; -import ImageViewing from "react-native-image-viewing"; import { AsyncResult } from "effect/unstable/reactivity"; import { AppText as Text } from "../../components/AppText"; import { EmptyState } from "../../components/EmptyState"; +import { FullScreenImageViewer } from "../../components/FullScreenImageViewer"; +import type { FullScreenImageSource } from "../../lib/fullScreenImageActions"; import { workspaceFileImageAtom } from "./workspace-file-image-cache"; function ResolvedWorkspaceFileImagePreview(props: { @@ -13,12 +14,11 @@ function ResolvedWorkspaceFileImagePreview(props: { readonly uri: string; }) { const [loadError, setLoadError] = useState(null); - const [fullScreenVisible, setFullScreenVisible] = useState(false); + const [fullScreenSource, setFullScreenSource] = useState(null); const imageSource = useMemo( () => ({ uri: props.uri, cache: "force-cache" as const }), [props.uri], ); - const fullScreenImages = useMemo(() => [imageSource], [imageSource]); return ( @@ -27,7 +27,13 @@ function ResolvedWorkspaceFileImagePreview(props: { accessibilityLabel={`Open full-screen preview of ${props.accessibilityLabel}`} disabled={loadError !== null} className="flex-1 p-4 active:bg-subtle-strong" - onPress={() => setFullScreenVisible(true)} + onPress={() => + setFullScreenSource({ + uri: props.uri, + fileName: props.accessibilityLabel, + cache: "force-cache", + }) + } > ) : null} - setFullScreenVisible(false)} - swipeToCloseEnabled - doubleTapToZoomEnabled + setFullScreenSource(null)} /> ); diff --git a/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx b/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx index c6d678ddca9..279313891b1 100644 --- a/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx +++ b/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx @@ -12,12 +12,12 @@ import { } from "react-native"; import { KeyboardAvoidingView, KeyboardStickyView } from "react-native-keyboard-controller"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import ImageViewing from "react-native-image-viewing"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { SymbolView } from "../../components/AppSymbol"; import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; import { ControlPill } from "../../components/ControlPill"; +import { FullScreenImageViewer } from "../../components/FullScreenImageViewer"; import { cn } from "../../lib/cn"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages"; @@ -63,6 +63,10 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp >({}); const [attachments, setAttachments] = useState>([]); const [previewImageUri, setPreviewImageUri] = useState(null); + const previewSource = useMemo( + () => (previewImageUri === null ? null : { uri: previewImageUri }), + [previewImageUri], + ); const selectedLines = useMemo( () => (target ? getSelectedReviewCommentLines(target) : []), @@ -337,13 +341,9 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp ) : null} - setPreviewImageUri(null)} - swipeToCloseEnabled - doubleTapToZoomEnabled /> ); diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index fc45cba4260..7a25014e5a4 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -25,7 +25,6 @@ import { View, type ViewStyle, } from "react-native"; -import ImageViewing from "react-native-image-viewing"; import Animated, { FadeIn, FadeInDown, @@ -51,6 +50,7 @@ import { ComposerToolbarTrigger, } from "../../components/ComposerToolbarTrigger"; import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; +import { FullScreenImageViewer } from "../../components/FullScreenImageViewer"; import { ProviderIcon } from "../../components/ProviderIcon"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; @@ -277,6 +277,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const { onExpandedChange } = props; const [previewImageUri, setPreviewImageUri] = useState(null); + const previewSource = useMemo( + () => (previewImageUri === null ? null : { uri: previewImageUri }), + [previewImageUri], + ); const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0; const isExpanded = isFocused; const canSend = hasContent; @@ -922,14 +926,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ) : null} - + ); }); diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 8ad117c8635..0e56c82b676 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -43,7 +43,6 @@ import { View, } from "react-native"; import { TouchableOpacity } from "react-native-gesture-handler"; -import ImageViewing from "react-native-image-viewing"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import Animated, { FadeIn, @@ -65,6 +64,8 @@ import { import { AppText as Text } from "../../components/AppText"; import { CopyTextButton } from "../../components/CopyTextButton"; +import { FullScreenImageViewer } from "../../components/FullScreenImageViewer"; +import { useShareImage } from "../../lib/useShareImage"; import { parseReviewCommentMessageSegments, type ReviewInlineComment, @@ -176,6 +177,7 @@ function MessageAttachmentImage(props: { _tag: "attachment", attachmentId: props.attachmentId, }); + const share = useShareImage(); if (uri === null) { return ( @@ -186,7 +188,11 @@ function MessageAttachmentImage(props: { } return ( - props.onPressImage(uri)}> + props.onPressImage(uri)} + onLongPress={() => share({ uri })} + > ); @@ -1916,23 +1922,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ) : null} - setExpandedImage(null)} - swipeToCloseEnabled - doubleTapToZoomEnabled - /> + setExpandedImage(null)} /> ); }); diff --git a/apps/mobile/src/lib/fullScreenImageActions.test.ts b/apps/mobile/src/lib/fullScreenImageActions.test.ts new file mode 100644 index 00000000000..b881df97f84 --- /dev/null +++ b/apps/mobile/src/lib/fullScreenImageActions.test.ts @@ -0,0 +1,202 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + create: vi.fn(), + copy: vi.fn(), + directoryCreate: vi.fn(), + directoryDelete: vi.fn(), + downloadFileAsync: vi.fn(), + write: vi.fn(), + isAvailableAsync: vi.fn(), + shareAsync: vi.fn(), +})); + +class FakeFile { + readonly uri: string; + constructor(...segments: ReadonlyArray<{ uri: string } | string>) { + this.uri = segments + .map((segment) => (typeof segment === "string" ? segment : segment.uri)) + .join("/"); + } + create = (options?: unknown) => mocks.create(this.uri, options); + copy = (destination: FakeFile) => mocks.copy(this.uri, destination.uri); + write = (content: string, options?: unknown) => mocks.write(this.uri, content, options); + static downloadFileAsync = (url: string, destination: FakeFile, options?: unknown) => + mocks.downloadFileAsync(url, destination, options); +} + +class FakeDirectory { + readonly uri: string; + constructor(...segments: ReadonlyArray<{ uri: string } | string>) { + this.uri = segments + .map((segment) => (typeof segment === "string" ? segment : segment.uri)) + .join("/"); + } + create = (options?: unknown) => mocks.directoryCreate(this.uri, options); + delete = () => mocks.directoryDelete(this.uri); +} + +vi.mock("expo-file-system", () => ({ + Directory: FakeDirectory, + File: FakeFile, + Paths: { cache: { uri: "file:///cache" } }, +})); + +vi.mock("expo-sharing", () => ({ + isAvailableAsync: mocks.isAvailableAsync, + shareAsync: mocks.shareAsync, +})); + +import { + SHARE_FAILED_MESSAGE, + SHARING_UNAVAILABLE_MESSAGE, + imageUriMetadata, + shareImage, +} from "./fullScreenImageActions"; + +/** A signed asset URL of the shape `assets.createUrl` mints. */ +const SIGNED_ASSET_URL = + "https://relay.example.test/api/assets/eyJhIjoxfQ.s3cr3tS1gnatur3/light-ui.png"; + +describe("imageUriMetadata", () => { + it("keeps the signed token out of asset URL diagnostics", () => { + const metadata = imageUriMetadata(SIGNED_ASSET_URL); + + expect(metadata).toEqual({ scheme: "https", host: "relay.example.test" }); + expect(JSON.stringify(metadata)).not.toContain("s3cr3tS1gnatur3"); + }); + + it("reduces a data URI to its media type", () => { + expect(imageUriMetadata("data:image/png;base64,U0VDUkVU")).toEqual({ + scheme: "data", + host: "image/png", + }); + expect(imageUriMetadata("data:image/png;charset=utf-8;base64,U0VDUkVU")).toEqual({ + scheme: "data", + host: "image/png", + }); + }); + + it("handles local files and unparseable input", () => { + expect(imageUriMetadata("file:///tmp/shot.png").scheme).toBe("file"); + expect(imageUriMetadata("not a url").scheme).toBe("unknown"); + }); +}); + +describe("shareImage", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.isAvailableAsync.mockResolvedValue(true); + mocks.shareAsync.mockResolvedValue(undefined); + mocks.downloadFileAsync.mockImplementation(async (_url, destination) => destination); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("shares a local file directly and never deletes anything", async () => { + const result = await shareImage({ uri: "file:///tmp/shot.png" }); + + expect(result).toEqual({ ok: true }); + expect(mocks.shareAsync).toHaveBeenCalledWith( + "file:///tmp/shot.png", + expect.objectContaining({ mimeType: "image/png", UTI: "public.png" }), + ); + expect(mocks.downloadFileAsync).not.toHaveBeenCalled(); + expect(mocks.directoryDelete).not.toHaveBeenCalled(); + }); + + it("copies an Android content:// URI into a real file before sharing", async () => { + const result = await shareImage({ uri: "content://media/external/images/media/42" }); + + expect(result).toEqual({ ok: true }); + expect(mocks.downloadFileAsync).not.toHaveBeenCalled(); + // Android's shareAsync throws on any scheme other than file, so the sheet + // must never be handed the content URI itself. + expect(mocks.copy).toHaveBeenCalledTimes(1); + const sharedUri = mocks.shareAsync.mock.calls[0]?.[0] as string; + expect(sharedUri).not.toContain("content://"); + expect(mocks.directoryDelete).toHaveBeenCalledTimes(1); + }); + + it("writes a data URI to a temp directory, shares it, then removes the directory", async () => { + const result = await shareImage({ uri: "data:image/png;base64,QUJD" }); + + expect(result).toEqual({ ok: true }); + expect(mocks.write).toHaveBeenCalledWith( + expect.stringContaining(".png"), + "QUJD", + expect.objectContaining({ encoding: "base64" }), + ); + expect(mocks.directoryDelete).toHaveBeenCalledTimes(1); + }); + + it("handles data URIs carrying extra media-type parameters", async () => { + const result = await shareImage({ uri: "data:image/png;charset=utf-8;base64,QUJD" }); + + expect(result).toEqual({ ok: true }); + expect(mocks.write).toHaveBeenCalledWith(expect.anything(), "QUJD", expect.anything()); + }); + + it("downloads a remote image, then removes the temp directory", async () => { + const result = await shareImage({ uri: "https://example.test/assets/a.png?revision=3" }); + + expect(result).toEqual({ ok: true }); + // The cache-buster must not be read as part of the extension. + expect(mocks.shareAsync).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ mimeType: "image/png" }), + ); + expect(mocks.directoryDelete).toHaveBeenCalledTimes(1); + }); + + it("keeps the display name intact while isolating each share in its own directory", async () => { + await shareImage({ uri: SIGNED_ASSET_URL, fileName: "../../etc/light-ui.png" }); + await shareImage({ uri: SIGNED_ASSET_URL, fileName: "light-ui.png" }); + + const [first, second] = mocks.downloadFileAsync.mock.calls.map((call) => call[1].uri); + expect(first).toContain("light-ui.png"); + expect(second).toContain("light-ui.png"); + expect(first).not.toBe(second); + }); + + it("removes the temp directory when writing the file fails", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + mocks.write.mockImplementation(() => { + throw new Error("storage exhausted"); + }); + + const result = await shareImage({ uri: "data:image/png;base64,QUJD" }); + + expect(result).toEqual({ ok: false, message: SHARE_FAILED_MESSAGE }); + expect(mocks.directoryDelete).toHaveBeenCalledTimes(1); + expect(mocks.shareAsync).not.toHaveBeenCalled(); + }); + + it("reports when sharing is unavailable instead of throwing", async () => { + mocks.isAvailableAsync.mockResolvedValue(false); + + const result = await shareImage({ uri: "file:///tmp/shot.png" }); + + expect(result).toEqual({ ok: false, message: SHARING_UNAVAILABLE_MESSAGE }); + expect(mocks.shareAsync).not.toHaveBeenCalled(); + }); + + it("logs neither the signed token nor image bytes, and records the failing stage", async () => { + const logged: Array<{ message: string; stage: string; host?: string }> = []; + vi.spyOn(console, "error").mockImplementation((value: unknown) => { + logged.push(value as { message: string; stage: string; host?: string }); + }); + mocks.shareAsync.mockRejectedValue(new Error("sheet failed")); + + await shareImage({ uri: SIGNED_ASSET_URL }); + await shareImage({ uri: "data:image/png;base64,U0VDUkVU" }); + + const serialized = logged.map((entry) => entry.message).join("\n"); + expect(serialized).not.toContain("s3cr3tS1gnatur3"); + expect(serialized).not.toContain("U0VDUkVU"); + expect(logged[0]?.host).toBe("relay.example.test"); + expect(logged[0]?.stage).toBe("share"); + }); +}); diff --git a/apps/mobile/src/lib/fullScreenImageActions.ts b/apps/mobile/src/lib/fullScreenImageActions.ts new file mode 100644 index 00000000000..6bc99537ffe --- /dev/null +++ b/apps/mobile/src/lib/fullScreenImageActions.ts @@ -0,0 +1,218 @@ +import * as Schema from "effect/Schema"; +import * as Sharing from "expo-sharing"; +import type { ImageURISource } from "react-native"; + +export type FullScreenImageSource = { + readonly uri: string; + readonly fileName?: string; + /** Forwarded to `react-native-image-viewing`'s underlying `Image` source. */ + readonly cache?: ImageURISource["cache"]; +}; + +export type ImageActionResult = + | { readonly ok: true } + | { readonly ok: false; readonly message: string }; + +export const SHARING_UNAVAILABLE_MESSAGE = "Sharing isn't available on this device."; +export const SHARE_FAILED_MESSAGE = "Couldn't share the image."; + +export class ImageShareError extends Schema.TaggedErrorClass()("ImageShareError", { + stage: Schema.Literals(["materialize", "share"]), + scheme: Schema.String, + host: Schema.optional(Schema.String), + cause: Schema.Defect(), +}) { + override get message(): string { + const action = this.stage === "materialize" ? "prepare" : "share"; + const from = this.host === undefined ? "" : ` from ${this.host}`; + return `Failed to ${action} the ${this.scheme} image${from}.`; + } +} + +const CACHE_DIRECTORY_NAME = "fullscreen-image-share"; +const DATA_URI_PATTERN = /^data:([^;,]*)(?:;[^;,=]+=[^;,]+)*(?:(;base64))?,/i; + +/** iOS needs a UTI alongside the mime type for the sheet to offer the right targets. */ +const IMAGE_TYPES: ReadonlyArray<{ + readonly extension: string; + readonly mimeType: string; + readonly uti: string; +}> = [ + { extension: "png", mimeType: "image/png", uti: "public.png" }, + { extension: "jpg", mimeType: "image/jpeg", uti: "public.jpeg" }, + { extension: "jpeg", mimeType: "image/jpeg", uti: "public.jpeg" }, + { extension: "gif", mimeType: "image/gif", uti: "com.compuserve.gif" }, + { extension: "webp", mimeType: "image/webp", uti: "org.webmproject.webp" }, + { extension: "heic", mimeType: "image/heic", uti: "public.heic" }, + { extension: "bmp", mimeType: "image/bmp", uti: "com.microsoft.bmp" }, +]; + +type ImageType = (typeof IMAGE_TYPES)[number]; + +let temporaryDirectoryCounter = 0; + +/** + * Safe diagnostics for an image URI, mirroring `openExternalUrl`. Asset URLs are + * signed capabilities and `data:` URIs are the image bytes, so neither may be + * logged whole. For a `data:` URI `host` carries the media type instead. + */ +export function imageUriMetadata(uri: string): { + readonly scheme: string; + readonly host?: string; +} { + const dataMatch = DATA_URI_PATTERN.exec(uri); + if (dataMatch !== null) { + return { scheme: "data", host: dataMatch[1] || "application/octet-stream" }; + } + try { + const parsed = new URL(uri); + return { + scheme: parsed.protocol.replace(/:$/, "") || "unknown", + host: parsed.hostname || undefined, + }; + } catch { + return { scheme: /^([a-z][a-z\d+.-]*):/i.exec(uri)?.[1]?.toLowerCase() ?? "unknown" }; + } +} + +function imageTypeFor(uri: string): ImageType | null { + const dataMatch = DATA_URI_PATTERN.exec(uri); + if (dataMatch !== null) { + const mimeType = dataMatch[1]?.toLowerCase(); + return IMAGE_TYPES.find((type) => type.mimeType === mimeType) ?? null; + } + + // Only the last path segment, so dots in the hostname are not read as an extension. + const lastSegment = (uri.split(/[?#]/, 1)[0] ?? "").split("/").pop() ?? ""; + const dotIndex = lastSegment.lastIndexOf("."); + if (dotIndex <= 0) { + return null; + } + const extension = lastSegment.slice(dotIndex + 1).toLowerCase(); + return IMAGE_TYPES.find((type) => type.extension === extension) ?? null; +} + +/** Returns "" when nothing usable is left, so the caller falls back to a generic name. */ +function sanitizeFileNameStem(fileName: string): string { + const withoutDirectories = fileName.split(/[\\/]/).pop() ?? ""; + const dotIndex = withoutDirectories.lastIndexOf("."); + const stem = dotIndex > 0 ? withoutDirectories.slice(0, dotIndex) : withoutDirectories; + return stem.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^[.-]+|-+$/g, ""); +} + +function temporaryFileName(source: FullScreenImageSource): string { + const extension = imageTypeFor(source.uri)?.extension ?? "img"; + const stem = source.fileName ? sanitizeFileNameStem(source.fileName) : ""; + return `${stem.length > 0 ? stem : "image"}.${extension}`; +} + +/** + * Only a real file can be handed straight to the sheet. Android's `shareAsync` + * rejects every other scheme outright, so `content://` is copied first even + * though the bytes are already on the device. + */ +function isShareableFileUri(uri: string): boolean { + return uri.startsWith("file://") || uri.startsWith("/"); +} + +function isContentUri(uri: string): boolean { + return uri.startsWith("content://"); +} + +/** + * A fresh directory per share. The file inside keeps its display name, which is + * what the share sheet shows, while the unique parent stops two shares of + * same-named images from overwriting each other. + */ +async function createTemporaryDirectory() { + const { Directory, Paths } = await import("expo-file-system"); + temporaryDirectoryCounter += 1; + const directory = new Directory( + Paths.cache, + CACHE_DIRECTORY_NAME, + String(temporaryDirectoryCounter), + ); + directory.create({ idempotent: true, intermediates: true }); + return directory; +} + +function deleteQuietly(target: { delete: () => void }): void { + try { + target.delete(); + } catch { + // A leftover entry in the cache directory is harmless; the OS reclaims it. + } +} + +type MaterializedImage = { + readonly file: { readonly uri: string }; + /** Set when we created a temporary directory that must be removed afterwards. */ + readonly temporaryDirectory: { delete: () => void } | null; +}; + +/** `Sharing.shareAsync` only accepts a local file, so remote and data URIs land on disk first. */ +async function materializeImageFile(source: FullScreenImageSource): Promise { + const { File } = await import("expo-file-system"); + + if (isShareableFileUri(source.uri)) { + return { file: new File(source.uri), temporaryDirectory: null }; + } + + const directory = await createTemporaryDirectory(); + try { + const dataMatch = DATA_URI_PATTERN.exec(source.uri); + if (dataMatch !== null) { + if (dataMatch[2] === undefined) { + throw new Error("Only base64-encoded data URIs are supported."); + } + const file = new File(directory, temporaryFileName(source)); + file.create({ overwrite: true }); + file.write(source.uri.slice(dataMatch[0].length), { encoding: "base64" }); + return { file, temporaryDirectory: directory }; + } + + if (isContentUri(source.uri)) { + const destination = new File(directory, temporaryFileName(source)); + await new File(source.uri).copy(destination); + return { file: destination, temporaryDirectory: directory }; + } + + const destination = new File(directory, temporaryFileName(source)); + const downloaded = await File.downloadFileAsync(source.uri, destination, { idempotent: true }); + return { file: downloaded, temporaryDirectory: directory }; + } catch (cause) { + // The directory is ours and the caller never sees it, so clean up here. + deleteQuietly(directory); + throw cause; + } +} + +export async function shareImage(source: FullScreenImageSource): Promise { + let materialized: MaterializedImage | null = null; + let stage: "materialize" | "share" = "materialize"; + try { + if (!(await Sharing.isAvailableAsync())) { + return { ok: false, message: SHARING_UNAVAILABLE_MESSAGE }; + } + + materialized = await materializeImageFile(source); + stage = "share"; + const imageType = imageTypeFor(source.uri); + + // Resolves only after the sheet is dismissed, so the file outlives every read. + await Sharing.shareAsync(materialized.file.uri, { + dialogTitle: source.fileName, + mimeType: imageType?.mimeType, + UTI: imageType?.uti, + }); + + return { ok: true }; + } catch (cause) { + console.error(new ImageShareError({ stage, ...imageUriMetadata(source.uri), cause })); + return { ok: false, message: SHARE_FAILED_MESSAGE }; + } finally { + if (materialized?.temporaryDirectory) { + deleteQuietly(materialized.temporaryDirectory); + } + } +} diff --git a/apps/mobile/src/lib/useShareImage.test.ts b/apps/mobile/src/lib/useShareImage.test.ts new file mode 100644 index 00000000000..b2f445585c7 --- /dev/null +++ b/apps/mobile/src/lib/useShareImage.test.ts @@ -0,0 +1,135 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + alert: vi.fn(), + shareImage: vi.fn(), +})); + +vi.mock("react-native", () => ({ + Alert: { alert: mocks.alert }, +})); + +vi.mock("./fullScreenImageActions", () => ({ + shareImage: mocks.shareImage, +})); + +import { shareImageExclusively } from "./useShareImage"; + +describe("shareImageExclusively", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("ignores a second request while a sheet is already open", async () => { + let release: (() => void) | undefined; + mocks.shareImage.mockReturnValue( + new Promise((resolve) => { + release = () => resolve({ ok: true }); + }), + ); + + // Two different thumbnails long-pressed before the first sheet appears. + const first = shareImageExclusively({ uri: "https://example.test/a.png" }); + const second = shareImageExclusively({ uri: "https://example.test/b.png" }); + + expect(mocks.shareImage).toHaveBeenCalledTimes(1); + + release?.(); + await Promise.all([first, second]); + }); + + it("allows a new share once the previous one settles", async () => { + mocks.shareImage.mockResolvedValue({ ok: true }); + + await shareImageExclusively({ uri: "https://example.test/a.png" }); + await shareImageExclusively({ uri: "https://example.test/b.png" }); + + expect(mocks.shareImage).toHaveBeenCalledTimes(2); + }); + + it("releases the guard when a share fails, and surfaces the message", async () => { + mocks.shareImage.mockResolvedValue({ ok: false, message: "Couldn't share the image." }); + + await shareImageExclusively({ uri: "https://example.test/a.png" }); + + expect(mocks.alert).toHaveBeenCalledWith("Couldn't share the image."); + + mocks.shareImage.mockResolvedValue({ ok: true }); + await shareImageExclusively({ uri: "https://example.test/b.png" }); + + expect(mocks.shareImage).toHaveBeenCalledTimes(2); + }); + + it("expires the lock so a sheet that never settles cannot disable sharing", async () => { + vi.useFakeTimers(); + try { + // A share that hangs forever, as some Android targets do after a cancel. + mocks.shareImage.mockReturnValueOnce(new Promise(() => {})); + void shareImageExclusively({ uri: "https://example.test/a.png" }); + expect(mocks.shareImage).toHaveBeenCalledTimes(1); + + // Still held while the lock is fresh. + vi.setSystemTime(Date.now() + 59_000); + void shareImageExclusively({ uri: "https://example.test/b.png" }); + expect(mocks.shareImage).toHaveBeenCalledTimes(1); + + // Released once it goes stale. + vi.setSystemTime(Date.now() + 2_000); + mocks.shareImage.mockResolvedValue({ ok: true }); + await shareImageExclusively({ uri: "https://example.test/c.png" }); + expect(mocks.shareImage).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it("does not let a stale share release the lock a newer share holds", async () => { + vi.useFakeTimers(); + try { + let releaseStale: (() => void) | undefined; + mocks.shareImage.mockReturnValueOnce( + new Promise((resolve) => { + releaseStale = () => resolve({ ok: true }); + }), + ); + // A hangs and its lock goes stale. + void shareImageExclusively({ uri: "https://example.test/a.png" }); + vi.setSystemTime(Date.now() + 61_000); + + // B takes the lock. + let releaseCurrent: (() => void) | undefined; + mocks.shareImage.mockReturnValueOnce( + new Promise((resolve) => { + releaseCurrent = () => resolve({ ok: true }); + }), + ); + const current = shareImageExclusively({ uri: "https://example.test/b.png" }); + expect(mocks.shareImage).toHaveBeenCalledTimes(2); + + // A finally settles. It must not clear B's lock. + releaseStale?.(); + await Promise.resolve(); + await Promise.resolve(); + + void shareImageExclusively({ uri: "https://example.test/c.png" }); + expect(mocks.shareImage).toHaveBeenCalledTimes(2); + + // Leave the module-level lock free for the next test. + releaseCurrent?.(); + await current; + } finally { + vi.useRealTimers(); + } + }); + + it("releases the guard when shareImage throws", async () => { + mocks.shareImage.mockRejectedValue(new Error("boom")); + + await expect(shareImageExclusively({ uri: "https://example.test/a.png" })).rejects.toThrow(); + + mocks.shareImage.mockResolvedValue({ ok: true }); + await shareImageExclusively({ uri: "https://example.test/b.png" }); + + expect(mocks.shareImage).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/mobile/src/lib/useShareImage.ts b/apps/mobile/src/lib/useShareImage.ts new file mode 100644 index 00000000000..8afb70a2cef --- /dev/null +++ b/apps/mobile/src/lib/useShareImage.ts @@ -0,0 +1,43 @@ +import { useCallback } from "react"; +import { Alert } from "react-native"; + +import { shareImage, type FullScreenImageSource } from "./fullScreenImageActions"; + +// Module scope, not per hook. The hook is called once per thumbnail and again +// by the fullscreen viewer, so a ref would guard each caller separately and +// still let two long-presses stack two system sheets. +// +// Held as a start time rather than a boolean so the lock expires on its own. A +// share sheet that never settles would otherwise disable sharing app-wide until +// the app restarts, which is a worse failure than the stacking it prevents. +const SHARE_LOCK_TIMEOUT_MS = 60_000; +let activeShare: { readonly startedAt: number } | null = null; + +/** Exported for tests. The hook is a thin wrapper around this. */ +export async function shareImageExclusively(source: FullScreenImageSource): Promise { + const startedAt = Date.now(); + if (activeShare !== null && startedAt - activeShare.startedAt < SHARE_LOCK_TIMEOUT_MS) { + return; + } + // Identity, not the timestamp: a share that settles after its lock expired + // must not release the lock a newer share has since taken. + const share = { startedAt }; + activeShare = share; + try { + const result = await shareImage(source); + if (!result.ok) { + Alert.alert(result.message); + } + } finally { + if (activeShare === share) { + activeShare = null; + } + } +} + +/** Opens the system share sheet for an image, one at a time across the app. */ +export function useShareImage() { + return useCallback((source: FullScreenImageSource) => { + void shareImageExclusively(source); + }, []); +}