Open History to see the last commit
1import {
2 clampFileAttachmentUploadBytes,
3 fileAttachmentTooLargeMessage,
4} from "@t3tools/client-runtime/state/attachments";
5import {
6 isProviderSendTurnSupportedImageMimeType,
7 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
8 PROVIDER_SEND_TURN_MAX_FILE_BYTES,
9 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
10} from "@t3tools/contracts";
11import * as Schema from "effect/Schema";
12import type { ResolvedSharePayload, SharePayload } from "expo-sharing";
13
14import { DraftComposerAttachmentSchema } from "../../lib/composer-image-schema";
15import { fitComposerImage } from "../../lib/composerImageFit";
16import type { DraftComposerAttachment } from "../../lib/composerImages";
17import { estimateBase64ByteSize } from "../../lib/base64";
18
19export interface IncomingShareDraft {
20 readonly schemaVersion: 1;
21 readonly id: string;
22 readonly createdAt: string;
23 readonly destination?: IncomingShareDestination;
24 readonly text: string;
25 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
26 readonly warnings: ReadonlyArray<string>;
27}
28
29export interface IncomingShareDestination {
30 readonly environmentId: string;
31 readonly projectId: string;
32}
33
34const IncomingShareDestinationSchema = Schema.Struct({
35 environmentId: Schema.String,
36 projectId: Schema.String,
37});
38
39export const IncomingShareDraftSchema = Schema.Struct({
40 schemaVersion: Schema.Literal(1),
41 id: Schema.String,
42 createdAt: Schema.String,
43 destination: Schema.optional(IncomingShareDestinationSchema),
44 text: Schema.String,
45 attachments: Schema.Array(DraftComposerAttachmentSchema),
46 warnings: Schema.Array(Schema.String),
47});
48
49const decodeIncomingShareDraftSync = Schema.decodeUnknownSync(IncomingShareDraftSchema);
50
51export function decodeIncomingShareDraft(value: unknown): IncomingShareDraft {
52 return decodeIncomingShareDraftSync(value);
53}
54
55/** Strip iOS /private so /var and /private/var paths compare equally. */
56function normalizedFileUriPath(uri: string): string | null {
57 try {
58 const url = new URL(uri);
59 if (url.protocol !== "file:") {
60 return null;
61 }
62 const path = decodeURIComponent(url.pathname);
63 // URL parsing leaves encoded separators; reject decoded .. segments before lexical containment checks.
64 if (path.split("/").includes("..")) {
65 return null;
66 }
67 return path.startsWith("/private/var/") ? path.slice("/private".length) : path;
68 } catch {
69 return null;
70 }
71}
72
73/** Delete only inside app-owned roots; iOS shares may reference the sender's original. */
74export function isShareFileUriUnderOwnedRoots(
75 uri: string,
76 ownedRootUris: ReadonlyArray<string>,
77): boolean {
78 const path = normalizedFileUriPath(uri);
79 if (path === null) {
80 return false;
81 }
82 return ownedRootUris.some((rootUri) => {
83 const rootPath = normalizedFileUriPath(rootUri);
84 if (rootPath === null) {
85 return false;
86 }
87 const root = rootPath.endsWith("/") ? rootPath : `${rootPath}/`;
88 return path.startsWith(root) && path.length > root.length;
89 });
90}
91
92export interface IncomingShareFileReader {
93 readonly readBase64: (uri: string) => Promise<string>;
94 readonly removeOwnedFile: (uri: string) => Promise<void> | void;
95 readonly persistFile?: (uri: string, name: string) => Promise<string>;
96 readonly readSize?: (uri: string) => Promise<number | null>;
97}
98
99export function selectIncomingShareAttachments(input: {
100 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
101 readonly maxFileAttachmentBytes: number | null;
102}): {
103 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
104 readonly warnings: ReadonlyArray<string>;
105} {
106 const attachments: DraftComposerAttachment[] = [];
107 const warnings: string[] = [];
108
109 for (const attachment of input.attachments) {
110 if (attachment.type === "image") {
111 attachments.push(attachment);
112 continue;
113 }
114 if (input.maxFileAttachmentBytes === null) {
115 warnings.push(`'${attachment.name}' was skipped because this server does not support files.`);
116 continue;
117 }
118 const maxFileAttachmentBytes = clampFileAttachmentUploadBytes(input.maxFileAttachmentBytes);
119 if (attachment.sizeBytes > maxFileAttachmentBytes) {
120 warnings.push(fileAttachmentTooLargeMessage(attachment.name, maxFileAttachmentBytes));
121 continue;
122 }
123 attachments.push(attachment);
124 }
125
126 return { attachments, warnings };
127}
128
129export function selectIncomingShareAttachmentsForServer(input: {
130 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
131 readonly serverConfig: {
132 readonly environment: {
133 readonly capabilities: {
134 readonly fileAttachments?: { readonly maxUploadBytes: number };
135 };
136 };
137 } | null;
138}):
139 | { readonly status: "pending" }
140 | {
141 readonly status: "ready";
142 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
143 readonly warnings: ReadonlyArray<string>;
144 } {
145 const hasFiles = input.attachments.some((attachment) => attachment.type === "file");
146 if (hasFiles && input.serverConfig === null) {
147 return { status: "pending" };
148 }
149 const capabilities = input.serverConfig?.environment.capabilities;
150 const maxFileAttachmentBytes = capabilities?.fileAttachments?.maxUploadBytes ?? null;
151 return {
152 status: "ready",
153 ...selectIncomingShareAttachments({
154 attachments: input.attachments,
155 maxFileAttachmentBytes,
156 }),
157 };
158}
159
160function sharedText(payloads: ReadonlyArray<SharePayload>): string {
161 const seen = new Set<string>();
162 const values: string[] = [];
163 for (const payload of payloads) {
164 if (payload.shareType !== "text" && payload.shareType !== "url") {
165 continue;
166 }
167 const value = payload.value.trim();
168 if (!value || seen.has(value)) {
169 continue;
170 }
171 seen.add(value);
172 values.push(value);
173 }
174 return values.join("\n\n");
175}
176
177function resolvedImageFor(
178 payload: SharePayload,
179 index: number,
180 resolvedPayloads: ReadonlyArray<ResolvedSharePayload>,
181 consumedIndexes: Set<number>,
182): ResolvedSharePayload | undefined {
183 const sameIndex = resolvedPayloads[index];
184 if (
185 !consumedIndexes.has(index) &&
186 sameIndex?.shareType === payload.shareType &&
187 sameIndex.value === payload.value
188 ) {
189 consumedIndexes.add(index);
190 return sameIndex;
191 }
192 const matchingIndex = resolvedPayloads.findIndex(
193 (candidate, candidateIndex) =>
194 !consumedIndexes.has(candidateIndex) &&
195 candidate.shareType === payload.shareType &&
196 candidate.value === payload.value,
197 );
198 if (matchingIndex < 0) {
199 return undefined;
200 }
201 consumedIndexes.add(matchingIndex);
202 return resolvedPayloads[matchingIndex];
203}
204
205async function releaseOwnedFiles(
206 fileReader: IncomingShareFileReader,
207 uris: ReadonlyArray<string | undefined>,
208): Promise<void> {
209 for (const uri of new Set(uris.filter((candidate): candidate is string => Boolean(candidate)))) {
210 try {
211 await fileReader.removeOwnedFile(uri);
212 } catch {}
213 }
214}
215
216function fallbackName(uri: string, index: number, mimeType: string): string {
217 try {
218 const pathName = new URL(uri).pathname.split("/").findLast((segment) => segment.length > 0);
219 if (pathName) {
220 return decodeURIComponent(pathName);
221 }
222 } catch {}
223 const family = mimeType.split("/")[0]?.toLowerCase();
224 const kind = family === "image" || family === "audio" || family === "video" ? family : "file";
225 const extension =
226 mimeType.split("/")[1]?.replace(/[^a-z0-9.+-]/gi, "") || (kind === "image" ? "png" : "bin");
227 return `shared-${kind}-${index + 1}.${extension}`;
228}
229
230export async function buildIncomingShareDraft(input: {
231 readonly payloads: ReadonlyArray<SharePayload>;
232 readonly resolvedPayloads: ReadonlyArray<ResolvedSharePayload>;
233 readonly fileReader: IncomingShareFileReader;
234 readonly id: string;
235 readonly createdAt: string;
236}): Promise<IncomingShareDraft> {
237 const attachments: DraftComposerAttachment[] = [];
238 const warnings: string[] = [];
239 const consumedResolvedPayloadIndexes = new Set<number>();
240 let warnedAttachmentLimit = false;
241
242 for (const [index, payload] of input.payloads.entries()) {
243 if (
244 payload.shareType !== "image" &&
245 payload.shareType !== "file" &&
246 payload.shareType !== "audio" &&
247 payload.shareType !== "video"
248 ) {
249 continue;
250 }
251 const resolved = resolvedImageFor(
252 payload,
253 index,
254 input.resolvedPayloads,
255 consumedResolvedPayloadIndexes,
256 );
257 const uri = resolved?.contentUri ?? payload.value;
258 if (attachments.length >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {
259 if (!warnedAttachmentLimit) {
260 warnings.push(
261 `Only the first ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} shared ${payload.shareType === "image" ? "images" : "files"} were attached.`,
262 );
263 warnedAttachmentLimit = true;
264 }
265 await releaseOwnedFiles(input.fileReader, [uri, payload.value]);
266 continue;
267 }
268
269 const mimeType = (
270 resolved?.contentMimeType ??
271 payload.mimeType ??
272 (payload.shareType === "image" ? "image/png" : "application/octet-stream")
273 ).toLowerCase();
274 if (payload.shareType !== "image") {
275 const sharedFileName =
276 typeof payload.originalName === "string" && payload.originalName.trim().length > 0
277 ? payload.originalName
278 : undefined;
279 const name = resolved?.originalName ?? sharedFileName ?? fallbackName(uri, index, mimeType);
280 if (!uri) {
281 warnings.push("One shared file could not be read.");
282 continue;
283 }
284 let persistedFileUri: string | undefined;
285 let retainedFileUri: string | undefined;
286 try {
287 let sizeBytes = resolved?.contentSize ?? (await input.fileReader.readSize?.(uri)) ?? null;
288 if (
289 (sizeBytes === null || (sizeBytes === 0 && uri.startsWith("content:"))) &&
290 input.fileReader.persistFile
291 ) {
292 persistedFileUri = await input.fileReader.persistFile(uri, name);
293 sizeBytes = (await input.fileReader.readSize?.(persistedFileUri)) ?? null;
294 }
295 if (sizeBytes === null) {
296 warnings.push(`The size of '${name}' could not be determined.`);
297 if (persistedFileUri) {
298 await releaseOwnedFiles(input.fileReader, [persistedFileUri]);
299 }
300 continue;
301 }
302 if (sizeBytes <= 0) {
303 warnings.push(`'${name}' is empty or could not be read.`);
304 if (persistedFileUri) {
305 await releaseOwnedFiles(input.fileReader, [persistedFileUri]);
306 }
307 continue;
308 }
309 if (sizeBytes > PROVIDER_SEND_TURN_MAX_FILE_BYTES) {
310 warnings.push(fileAttachmentTooLargeMessage(name, PROVIDER_SEND_TURN_MAX_FILE_BYTES));
311 if (persistedFileUri) {
312 await releaseOwnedFiles(input.fileReader, [persistedFileUri]);
313 }
314 continue;
315 }
316 if (persistedFileUri === undefined && input.fileReader.persistFile) {
317 persistedFileUri = await input.fileReader.persistFile(uri, name);
318 // Android content sources can misreport size; record the stored copy's measured size.
319 const storedSize = (await input.fileReader.readSize?.(persistedFileUri)) ?? null;
320 if (storedSize !== null) {
321 sizeBytes = storedSize;
322 }
323 if (sizeBytes <= 0) {
324 warnings.push(`'${name}' is empty or could not be read.`);
325 await releaseOwnedFiles(input.fileReader, [persistedFileUri]);
326 continue;
327 }
328 if (sizeBytes > PROVIDER_SEND_TURN_MAX_FILE_BYTES) {
329 warnings.push(fileAttachmentTooLargeMessage(name, PROVIDER_SEND_TURN_MAX_FILE_BYTES));
330 await releaseOwnedFiles(input.fileReader, [persistedFileUri]);
331 continue;
332 }
333 }
334 attachments.push({
335 id: `${input.id}:file:${index}`,
336 type: "file",
337 name,
338 mimeType,
339 sizeBytes,
340 fileUri: persistedFileUri ?? uri,
341 });
342 retainedFileUri = persistedFileUri ?? uri;
343 } catch (error) {
344 warnings.push(error instanceof Error ? error.message : `Could not read '${name}'.`);
345 if (persistedFileUri !== undefined) {
346 await releaseOwnedFiles(input.fileReader, [persistedFileUri]);
347 }
348 } finally {
349 await releaseOwnedFiles(
350 input.fileReader,
351 [uri, payload.value].filter((candidate) => candidate !== retainedFileUri),
352 );
353 }
354 continue;
355 }
356 if (!uri || !mimeType.startsWith("image/")) {
357 warnings.push("One shared item was not a supported image.");
358 await releaseOwnedFiles(input.fileReader, [uri, payload.value]);
359 continue;
360 }
361 if (!isProviderSendTurnSupportedImageMimeType(mimeType)) {
362 warnings.push(
363 `'${resolved?.originalName ?? fallbackName(uri, index, mimeType)}' is not a supported image type.`,
364 );
365 await releaseOwnedFiles(input.fileReader, [uri, payload.value]);
366 continue;
367 }
368 if (
369 resolved?.contentSize !== null &&
370 resolved?.contentSize !== undefined &&
371 resolved.contentSize > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES
372 ) {
373 warnings.push(
374 `'${resolved.originalName ?? fallbackName(uri, index, mimeType)}' exceeds the 10 MB attachment limit.`,
375 );
376 await releaseOwnedFiles(input.fileReader, [uri, payload.value]);
377 continue;
378 }
379
380 try {
381 const sharedBase64 = await input.fileReader.readBase64(uri);
382 // Use already-read bytes because native image loaders may not read the provider's file.
383 const fitted = await fitComposerImage({
384 uri: `data:${mimeType};base64,${sharedBase64}`,
385 mimeType,
386 base64: sharedBase64,
387 });
388 const base64 = fitted?.base64 ?? sharedBase64;
389 const sizeBytes = fitted
390 ? estimateBase64ByteSize(base64)
391 : (resolved?.contentSize ?? estimateBase64ByteSize(base64));
392 if (sizeBytes <= 0 || sizeBytes > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) {
393 warnings.push(
394 `'${resolved?.originalName ?? fallbackName(uri, index, mimeType)}' exceeds the 10 MB attachment limit.`,
395 );
396 continue;
397 }
398 const dataUrl = `data:${mimeType};base64,${base64}`;
399 attachments.push({
400 id: `${input.id}:image:${index}`,
401 type: "image",
402 name: resolved?.originalName ?? fallbackName(uri, index, mimeType),
403 mimeType,
404 sizeBytes,
405 dataUrl,
406 // Data-backed previews outlive temporary share files and App Group entries.
407 previewUri: dataUrl,
408 });
409 } catch {
410 warnings.push(`Could not read '${fallbackName(uri, index, mimeType)}'.`);
411 } finally {
412 await releaseOwnedFiles(input.fileReader, [uri, payload.value]);
413 }
414 }
415
416 return {
417 schemaVersion: 1,
418 id: input.id,
419 createdAt: input.createdAt,
420 text: sharedText(input.payloads),
421 attachments,
422 warnings,
423 };
424}
425
426export function hasIncomingShareContent(draft: IncomingShareDraft): boolean {
427 return draft.text.trim().length > 0 || draft.attachments.length > 0;
428}