Skip to content
Open
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
5 changes: 3 additions & 2 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,12 +142,13 @@ const sharingPlugin: NonNullable<ExpoConfig["plugins"]>[number] = [
supportsText: true,
supportsWebUrlWithMaxCount: 1,
supportsImageWithMaxCount: 8,
supportsFileWithMaxCount: 8,
},
},
android: {
enabled: true,
singleShareMimeTypes: ["text/plain", "image/*"],
multipleShareMimeTypes: ["image/*"],
singleShareMimeTypes: ["*/*"],
multipleShareMimeTypes: ["*/*"],
},
},
];
Expand Down
53 changes: 36 additions & 17 deletions apps/mobile/src/components/ComposerAttachmentStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ import { SymbolView } from "../components/AppSymbol";
import { Image, Pressable, ScrollView, View } from "react-native";
import { useThemeColor } from "../lib/useThemeColor";

import type { DraftComposerImageAttachment } from "../lib/composerImages";
import { AppText as Text } from "./AppText";
import type { DraftComposerAttachment } from "../lib/composerImages";

export interface ComposerAttachmentStripProps {
/** Attachment images to display. */
readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;
/** Called when the user taps the remove button on an image. */
/** Attachments to display. */
readonly attachments: ReadonlyArray<DraftComposerAttachment>;
/** Called when the user removes an attachment. */
readonly onRemove: (imageId: string) => void;
/** Called when the user taps on an image thumbnail to preview it. */
readonly onPressImage?: (previewUri: string) => void;
Expand All @@ -20,8 +21,7 @@ export interface ComposerAttachmentStripProps {
}

/**
* A horizontally-scrollable strip of image attachment thumbnails with remove
* buttons. Used by both the thread composer and the new-task draft screen.
* Attachment thumbnails used by the thread composer and the new-task draft screen.
*/
export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) {
const subtleBg = useThemeColor("--color-subtle");
Expand All @@ -42,37 +42,56 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) {
className="grow-0"
>
<View className="flex-row gap-2.5">
{props.attachments.map((image) => (
{props.attachments.map((attachment) => (
<View
key={image.id}
key={attachment.id}
className="relative"
style={{
paddingTop: removeButtonGutter,
paddingRight: removeButtonGutter,
}}
>
<Pressable
onPress={props.onPressImage ? () => props.onPressImage!(image.previewUri) : undefined}
>
<Image
source={{ uri: image.previewUri }}
{attachment.type === "image" ? (
<Pressable
onPress={
props.onPressImage ? () => props.onPressImage!(attachment.previewUri) : undefined
}
>
<Image
source={{ uri: attachment.previewUri }}
style={{
width: size,
height: size,
borderRadius: radius,
backgroundColor: subtleBg,
}}
resizeMode="cover"
/>
</Pressable>
) : (
<View
className="items-center justify-center gap-1 px-2"
style={{
width: size,
height: size,
borderRadius: radius,
backgroundColor: subtleBg,
}}
resizeMode="cover"
/>
</Pressable>
>
<SymbolView name="doc.text" size={22} tintColor="#a3a3a3" type="monochrome" />
<Text className="w-full text-center text-2xs text-foreground" numberOfLines={1}>
{attachment.name}
</Text>
</View>
)}
<Pressable
className="absolute h-[22px] w-[22px] items-center justify-center rounded-[11px] bg-black/55"
style={{
top: removeButtonPlacement === "gutter" ? 0 : 4,
right: removeButtonPlacement === "gutter" ? 0 : 4,
}}
hitSlop={6}
onPress={() => props.onRemove(image.id)}
onPress={() => props.onRemove(attachment.id)}
>
<SymbolView
name="xmark"
Expand Down
28 changes: 18 additions & 10 deletions apps/mobile/src/features/sharing/IncomingShareProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
} from "./incoming-share-model";
import { createIncomingSharePayloadReader } from "./incoming-share-native";
import { IncomingShareInbox } from "./incoming-share-inbox";
import { persistComposerAttachmentFile } from "../../lib/composerImages";
import {
loadIncomingShareDrafts,
removeIncomingShareDraft,
Expand Down Expand Up @@ -54,7 +55,7 @@ const getIncomingSharePayloads = createIncomingSharePayloadReader({
readPayloads: getSharedPayloads,
});

async function resolvedPayloadsForImages(): Promise<ReadonlyArray<ResolvedSharePayload>> {
async function resolvedPayloadsForFiles(): Promise<ReadonlyArray<ResolvedSharePayload>> {
try {
return await getResolvedSharedPayloadsAsync();
} catch (error) {
Expand Down Expand Up @@ -84,6 +85,11 @@ async function readBase64(uri: string): Promise<string> {
return new File(uri).base64();
}

async function readFileSize(uri: string): Promise<number> {
const { File } = await import("expo-file-system");
return new File(uri).size ?? 0;
}

async function removeOwnedFile(uri: string): Promise<void> {
if (!uri.startsWith("file:")) {
return;
Expand All @@ -99,21 +105,19 @@ async function removeOwnedFile(uri: string): Promise<void> {
}
}

async function removeReplayedImagePayloadFiles(
payloads: ReadonlyArray<SharePayload>,
): Promise<void> {
async function removeReplayedPayloadFiles(payloads: ReadonlyArray<SharePayload>): Promise<void> {
const uris = new Set<string>();
for (const payload of payloads) {
if (payload.shareType === "image") {
if (["image", "file", "audio", "video"].includes(payload.shareType)) {
uris.add(payload.value);
}
}
if (uris.size === 0) {
return;
}
const resolvedPayloads = await resolvedPayloadsForImages();
const resolvedPayloads = await resolvedPayloadsForFiles();
for (const payload of resolvedPayloads) {
if (payload.shareType === "image" && payload.contentUri) {
if (["image", "file", "audio", "video"].includes(payload.shareType) && payload.contentUri) {
uris.add(payload.contentUri);
}
}
Expand All @@ -131,14 +135,18 @@ const incomingShareInbox = new IncomingShareInbox({
clearPayloads: clearSharedPayloads,
buildDraft: async ({ payloads, id, createdAt }) => {
const cleanupUris = new Set<string>();
const resolvedPayloads = payloads.some((payload) => payload.shareType === "image")
? await resolvedPayloadsForImages()
const resolvedPayloads = payloads.some((payload) =>
["image", "file", "audio", "video"].includes(payload.shareType),
)
? await resolvedPayloadsForFiles()
: [];
const draft = await buildIncomingShareDraft({
payloads,
resolvedPayloads,
fileReader: {
readBase64,
persistFile: persistComposerAttachmentFile,
readSize: readFileSize,
removeOwnedFile: (uri) => {
cleanupUris.add(uri);
},
Expand All @@ -153,7 +161,7 @@ const incomingShareInbox = new IncomingShareInbox({
},
};
},
cleanupReplayedPayloads: removeReplayedImagePayloadFiles,
cleanupReplayedPayloads: removeReplayedPayloadFiles,
idForPayloads: incomingShareIdForPayloads,
now: () => new Date().toISOString(),
onClearError: (error) => {
Expand Down
102 changes: 102 additions & 0 deletions apps/mobile/src/features/sharing/incoming-share-model.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "@effect/vitest";
import {
PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
PROVIDER_SEND_TURN_MAX_FILE_BYTES,
PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
} from "@t3tools/contracts";
import type { ResolvedSharePayload, SharePayload } from "expo-sharing";
Expand Down Expand Up @@ -96,6 +97,107 @@ describe("incoming native shares", () => {
expect(hasIncomingShareContent(result)).toBe(false);
});

it("keeps a shared PDF on disk without converting its contents to base64", async () => {
const file: SharePayload = {
shareType: "file",
value: "file:///shared/report.pdf",
mimeType: "application/pdf",
};
const readBase64 = vi.fn(async () => "unused");
const persistFile = vi.fn(async () => "file:///documents/report.pdf");
const removeOwnedFile = vi.fn(async () => undefined);

const result = await buildIncomingShareDraft({
id: "share-report",
createdAt: "2026-07-15T10:00:00.000Z",
payloads: [file],
resolvedPayloads: [
{
...file,
contentUri: file.value,
contentType: "file",
contentMimeType: "application/pdf",
contentSize: 42,
originalName: "report.pdf",
},
],
fileReader: { readBase64, persistFile, removeOwnedFile },
});

expect(result.attachments).toEqual([
{
id: "share-report:file:0",
type: "file",
name: "report.pdf",
mimeType: "application/pdf",
sizeBytes: 42,
fileUri: "file:///documents/report.pdf",
},
]);
expect(readBase64).not.toHaveBeenCalled();
expect(persistFile).toHaveBeenCalledWith(file.value, "report.pdf");
expect(removeOwnedFile).toHaveBeenCalledWith(file.value);
});

it("rejects shared files that exceed the generic attachment limit", async () => {
const file: SharePayload = {
shareType: "file",
value: "file:///shared/huge.zip",
mimeType: "application/zip",
};
const persistFile = vi.fn(async () => "file:///documents/huge.zip");
const removeOwnedFile = vi.fn(async () => undefined);

const result = await buildIncomingShareDraft({
id: "share-huge",
createdAt: "2026-07-15T10:00:00.000Z",
payloads: [file],
resolvedPayloads: [
{
...file,
contentUri: file.value,
contentType: "file",
contentMimeType: "application/zip",
contentSize: PROVIDER_SEND_TURN_MAX_FILE_BYTES + 1,
originalName: "huge.zip",
},
],
fileReader: {
readBase64: async () => "unused",
persistFile,
removeOwnedFile,
},
});

expect(result.attachments).toEqual([]);
expect(result.warnings).toEqual(["'huge.zip' exceeds the 50 MB attachment limit."]);
expect(persistFile).not.toHaveBeenCalled();
expect(removeOwnedFile).toHaveBeenCalledWith(file.value);
});

it("reports an unreadable shared file without calling it oversized", async () => {
const file: SharePayload = {
shareType: "file",
value: "file:///shared/empty.txt",
mimeType: "text/plain",
};

const result = await buildIncomingShareDraft({
id: "share-empty",
createdAt: "2026-07-15T10:00:00.000Z",
payloads: [file],
resolvedPayloads: [],
fileReader: {
readBase64: async () => "unused",
readSize: async () => 0,
removeOwnedFile: async () => undefined,
},
});

expect(result.attachments).toEqual([]);
expect(result.warnings).toEqual(["'empty.txt' is empty or could not be read."]);
});

it("releases every temporary file when a share exceeds the attachment limit", async () => {
const payloads = Array.from({ length: PROVIDER_SEND_TURN_MAX_ATTACHMENTS + 1 }, (_, index) => ({
shareType: "image" as const,
Expand Down
Loading
Loading