authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-16 16:29:17+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-16 14:29:17+00:00
logfdca15471d92e95e4ec5501f45dbf3ce81f8d991
tree4162c93f01e433f94eee7f6277b626709bf080fa
parent5d68fca8f210c137eee04da30ed25b5fa7e432d9
signature Signed by PGP key B5690EEEBB952194

Add native mobile share target support (#4021)

Co-authored-by: codex <codex@users.noreply.github.com>

26 files changed, 2343 insertions(+), 86 deletions(-)

apps/mobile/README.md+2-2
...@@ -35,8 +35,8 @@ vp run ios:dev...@@ -35,8 +35,8 @@ vp run ios:dev
35```35```
3636
37If your Xcode account only has a Personal Team, use a bundle identifier you control and opt into the37If your Xcode account only has a Personal Team, use a bundle identifier you control and opt into the
38reduced-capability local build. Personal Team builds omit the widget extension, push entitlement, and38reduced-capability local build. Personal Team builds omit the widget and share extensions, push
39native Sign in with Apple entitlement; builds without this opt-in are unchanged.39entitlement, and native Sign in with Apple entitlement; builds without this opt-in are unchanged.
4040
41```bash41```bash
42T3CODE_IOS_PERSONAL_TEAM=1 \42T3CODE_IOS_PERSONAL_TEAM=1 \
apps/mobile/app.config.ts+33-3
...@@ -84,6 +84,9 @@ function resolveAppVariant(value: string | undefined): AppVariant {...@@ -84,6 +84,9 @@ function resolveAppVariant(value: string | undefined): AppVariant {
84}84}
8585
86const variant = VARIANT_CONFIG[APP_VARIANT];86const variant = VARIANT_CONFIG[APP_VARIANT];
87const iosBundleIdentifier = isIosPersonalTeamBuild
88 ? personalTeamBundleIdentifier!
89 : variant.iosBundleIdentifier;
8790
88const dmSansFonts = {91const dmSansFonts = {
89 regular: "@expo-google-fonts/dm-sans/400Regular/DMSans_400Regular.ttf",92 regular: "@expo-google-fonts/dm-sans/400Regular/DMSans_400Regular.ttf",
...@@ -94,8 +97,8 @@ const dmSansFonts = {...@@ -94,8 +97,8 @@ const dmSansFonts = {
94const widgetsPlugin: NonNullable<ExpoConfig["plugins"]>[number] = [97const widgetsPlugin: NonNullable<ExpoConfig["plugins"]>[number] = [
95 "expo-widgets",98 "expo-widgets",
96 {99 {
97 bundleIdentifier: `${variant.iosBundleIdentifier}.widgets`,100 bundleIdentifier: `${iosBundleIdentifier}.widgets`,
98 groupIdentifier: `group.${variant.iosBundleIdentifier}`,101 groupIdentifier: `group.${iosBundleIdentifier}`,
99 enablePushNotifications: true,102 enablePushNotifications: true,
100 // Agent activity can update many times an hour; without the103 // Agent activity can update many times an hour; without the
101 // frequent-updates entitlement iOS throttles the update budget sooner.104 // frequent-updates entitlement iOS throttles the update budget sooner.
...@@ -111,6 +114,30 @@ const widgetsPlugin: NonNullable<ExpoConfig["plugins"]>[number] = [...@@ -111,6 +114,30 @@ const widgetsPlugin: NonNullable<ExpoConfig["plugins"]>[number] = [
111 },114 },
112];115];
113116
117const sharingPlugin: NonNullable<ExpoConfig["plugins"]>[number] = [
118 "expo-sharing",
119 {
120 ios: {
121 // Personal Teams cannot sign App Groups or extension targets. Keep the
122 // reduced-capability local build usable while release builds expose the
123 // real system share target.
124 enabled: !isIosPersonalTeamBuild,
125 extensionBundleIdentifier: `${iosBundleIdentifier}.sharing`,
126 appGroupId: `group.${iosBundleIdentifier}`,
127 activationRule: {
128 supportsText: true,
129 supportsWebUrlWithMaxCount: 1,
130 supportsImageWithMaxCount: 8,
131 },
132 },
133 android: {
134 enabled: true,
135 singleShareMimeTypes: ["text/plain", "image/*"],
136 multipleShareMimeTypes: ["image/*"],
137 },
138 },
139];
140
114// These aliases match the fonts' PostScript names on iOS. Register the same141// These aliases match the fonts' PostScript names on iOS. Register the same
115// names on Android so React Native and the native composer use one set of142// names on Android so React Native and the native composer use one set of
116// family names without waiting for runtime font loading.143// family names without waiting for runtime font loading.
...@@ -140,7 +167,7 @@ const config: ExpoConfig = {...@@ -140,7 +167,7 @@ const config: ExpoConfig = {
140 ios: {167 ios: {
141 icon: variant.assets.iosIcon,168 icon: variant.assets.iosIcon,
142 supportsTablet: true,169 supportsTablet: true,
143 bundleIdentifier: variant.iosBundleIdentifier,170 bundleIdentifier: iosBundleIdentifier,
144 // Pin code signing to the T3 Tools team so non-interactive `expo run:ios`171 // Pin code signing to the T3 Tools team so non-interactive `expo run:ios`
145 // does not fall back to a personal team (which cannot sign app groups,172 // does not fall back to a personal team (which cannot sign app groups,
146 // Sign in with Apple, or push notification entitlements).173 // Sign in with Apple, or push notification entitlements).
...@@ -202,6 +229,9 @@ const config: ExpoConfig = {...@@ -202,6 +229,9 @@ const config: ExpoConfig = {
202 ],229 ],
203 "expo-secure-store",230 "expo-secure-store",
204 "expo-sqlite",231 "expo-sqlite",
232 ...(isIosPersonalTeamBuild
233 ? [sharingPlugin]
234 : ["./plugins/withShareExtensionDisplayName.cjs", sharingPlugin]),
205 [235 [
206 "expo-notifications",236 "expo-notifications",
207 {237 {
apps/mobile/package.json+1
...@@ -91,6 +91,7 @@...@@ -91,6 +91,7 @@
91 "expo-paste-input": "^0.1.15",91 "expo-paste-input": "^0.1.15",
92 "expo-quick-actions": "^6.0.2",92 "expo-quick-actions": "^6.0.2",
93 "expo-secure-store": "~56.0.4",93 "expo-secure-store": "~56.0.4",
94 "expo-sharing": "~56.0.18",
94 "expo-splash-screen": "~56.0.10",95 "expo-splash-screen": "~56.0.10",
95 "expo-sqlite": "~56.0.5",96 "expo-sqlite": "~56.0.5",
96 "expo-symbols": "~56.0.6",97 "expo-symbols": "~56.0.6",
apps/mobile/plugins/withShareExtensionDisplayName.cjs created+93
...@@ -0,0 +1,93 @@
1"use strict";
2
3// expo-sharing intentionally uses a fixed target name and also uses that
4// internal target name as CFBundleDisplayName. Brand the extension while
5// leaving its initially-empty signing team intact: Expo uses that state to
6// select ios.appleTeamId and enable first-time profile provisioning.
7//
8// ORDERING: list this plugin BEFORE expo-sharing. Expo runs same-type mods in
9// reverse registration order, so this executes after the extension exists.
10
11const fs = require("fs");
12const path = require("path");
13const { withDangerousMod, withXcodeProject } = require("expo/config-plugins");
14
15const TARGET_NAME = "expo-sharing-extension";
16
17function stripComments(section) {
18 return Object.fromEntries(
19 Object.entries(section ?? {}).filter(([key]) => !key.endsWith("_comment")),
20 );
21}
22
23function findByName(section, name) {
24 return Object.entries(stripComments(section)).find(([, value]) => value?.name === name) ?? null;
25}
26
27function escapeXml(value) {
28 return value
29 .replaceAll("&", "&amp;")
30 .replaceAll("<", "&lt;")
31 .replaceAll(">", "&gt;")
32 .replaceAll('"', "&quot;")
33 .replaceAll("'", "&apos;");
34}
35
36function withDisplayNamePlist(config, displayName) {
37 return withDangerousMod(config, [
38 "ios",
39 (cfg) => {
40 const plistPath = path.join(cfg.modRequest.platformProjectRoot, TARGET_NAME, "Info.plist");
41 if (!fs.existsSync(plistPath)) {
42 throw new Error(
43 `${TARGET_NAME}/Info.plist was not generated. Register withShareExtensionDisplayName before expo-sharing.`,
44 );
45 }
46 const source = fs.readFileSync(plistPath, "utf8");
47 const displayNamePattern = /(<key>CFBundleDisplayName<\/key>\s*<string>)[^<]*(<\/string>)/;
48 if (!displayNamePattern.test(source)) {
49 throw new Error(`Could not update CFBundleDisplayName in ${plistPath}.`);
50 }
51 const next = source.replace(displayNamePattern, `$1${escapeXml(displayName)}$2`);
52 if (next !== source) {
53 fs.writeFileSync(plistPath, next);
54 }
55 return cfg;
56 },
57 ]);
58}
59
60function withExtensionDisplayNameBuildSetting(config, displayName) {
61 return withXcodeProject(config, (cfg) => {
62 const objects = cfg.modResults.hash.project.objects;
63 const targetEntry = findByName(objects.PBXNativeTarget, TARGET_NAME);
64 if (!targetEntry) {
65 throw new Error(
66 `${TARGET_NAME} Xcode target was not generated. Register withShareExtensionDisplayName before expo-sharing.`,
67 );
68 }
69 const target = targetEntry[1];
70 const configurationList = stripComments(objects.XCConfigurationList)[
71 target.buildConfigurationList
72 ];
73 if (!configurationList) {
74 throw new Error(`Could not find build configurations for ${TARGET_NAME}.`);
75 }
76 const buildConfigurations = stripComments(objects.XCBuildConfiguration);
77 for (const reference of configurationList.buildConfigurations ?? []) {
78 const buildConfiguration = buildConfigurations[reference.value];
79 if (buildConfiguration?.buildSettings) {
80 buildConfiguration.buildSettings.INFOPLIST_KEY_CFBundleDisplayName = `"${displayName}"`;
81 }
82 }
83 return cfg;
84 });
85}
86
87module.exports = function withShareExtensionDisplayName(config) {
88 const displayName = config.name;
89 return withExtensionDisplayNameBuildSetting(
90 withDisplayNamePlist(config, displayName),
91 displayName,
92 );
93};
apps/mobile/src/App.tsx+11-5
...@@ -11,6 +11,7 @@ import { createStaticNavigation, DarkTheme, DefaultTheme } from "@react-navigati...@@ -11,6 +11,7 @@ import { createStaticNavigation, DarkTheme, DefaultTheme } from "@react-navigati
11import { RegistryContext } from "@effect/atom-react";11import { RegistryContext } from "@effect/atom-react";
12import { ConfirmDialogHost } from "./components/ConfirmDialogHost";12import { ConfirmDialogHost } from "./components/ConfirmDialogHost";
13import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider";13import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider";
14import { IncomingShareProvider } from "./features/sharing/IncomingShareProvider";
14import { AppearancePreferencesProvider } from "./features/settings/appearance/AppearancePreferencesProvider";15import { AppearancePreferencesProvider } from "./features/settings/appearance/AppearancePreferencesProvider";
15import { RootStack } from "./Stack";16import { RootStack } from "./Stack";
16import { appAtomRegistry } from "./state/atom-registry";17import { appAtomRegistry } from "./state/atom-registry";
...@@ -26,7 +27,10 @@ const appLinking = {...@@ -26,7 +27,10 @@ const appLinking = {
26 // <scheme>://expo-development-client/?url=<packager> — that URL addresses27 // <scheme>://expo-development-client/?url=<packager> — that URL addresses
27 // the launcher, not app navigation. Without this filter it falls through28 // the launcher, not app navigation. Without this filter it falls through
28 // to the NotFound wildcard route on every dev launch.29 // to the NotFound wildcard route on every dev launch.
29 filter: (url: string) => !url.includes("expo-development-client"),30 // expo-sharing uses a private lifecycle URL only to wake the app. The
31 // persisted share inbox below owns navigation once the payload is durable.
32 filter: (url: string) =>
33 !url.includes("expo-development-client") && !url.includes("://expo-sharing"),
30};34};
3135
32const Navigation = createStaticNavigation(RootStack);36const Navigation = createStaticNavigation(RootStack);
...@@ -58,10 +62,12 @@ export default function App() {...@@ -58,10 +62,12 @@ export default function App() {
58 the system is in dark mode. */}62 the system is in dark mode. */}
59 {/* Blur target for Android dropdown backdrops — see appBlurTarget.ts. */}63 {/* Blur target for Android dropdown backdrops — see appBlurTarget.ts. */}
60 <BlurTargetView ref={appBlurTargetRef} style={{ flex: 1 }}>64 <BlurTargetView ref={appBlurTargetRef} style={{ flex: 1 }}>
61 <Navigation65 <IncomingShareProvider>
62 linking={appLinking}66 <Navigation
63 theme={colorScheme === "dark" ? DarkTheme : DefaultTheme}67 linking={appLinking}
64 />68 theme={colorScheme === "dark" ? DarkTheme : DefaultTheme}
69 />
70 </IncomingShareProvider>
65 <ConfirmDialogHost />71 <ConfirmDialogHost />
66 </BlurTargetView>72 </BlurTargetView>
67 {/* Anchored-menu overlays render here — in-window, so the73 {/* Anchored-menu overlays render here — in-window, so the
apps/mobile/src/Stack.tsx+24
...@@ -10,6 +10,7 @@ import {...@@ -10,6 +10,7 @@ import {
10 createNativeStackScreen,10 createNativeStackScreen,
11 type NativeStackNavigationOptions,11 type NativeStackNavigationOptions,
12} from "@react-navigation/native-stack";12} from "@react-navigation/native-stack";
13import { useEffect, useRef } from "react";
13import { DynamicColorIOS, Platform, Pressable, ScrollView, StyleSheet } from "react-native";14import { DynamicColorIOS, Platform, Pressable, ScrollView, StyleSheet } from "react-native";
14import { useResolveClassNames } from "uniwind";15import { useResolveClassNames } from "uniwind";
1516
...@@ -52,6 +53,11 @@ import {...@@ -52,6 +53,11 @@ import {
52 SettingsLegalDocumentExternalHeaderButton,53 SettingsLegalDocumentExternalHeaderButton,
53} from "./features/settings/components/SettingsLegalDocumentRouteScreen";54} from "./features/settings/components/SettingsLegalDocumentRouteScreen";
54import { useAppShortcuts } from "./features/shortcuts/useAppShortcuts";55import { useAppShortcuts } from "./features/shortcuts/useAppShortcuts";
56import { useIncomingShare } from "./features/sharing/IncomingShareProvider";
57import {
58 EMPTY_INCOMING_SHARE_PRESENTATION_STATE,
59 transitionIncomingSharePresentation,
60} from "./features/sharing/incoming-share-presentation";
55import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader";61import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader";
56import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain";62import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain";
5763
...@@ -275,12 +281,30 @@ function RootStackLayout(props: {...@@ -275,12 +281,30 @@ function RootStackLayout(props: {
275 readonly children: React.ReactNode;281 readonly children: React.ReactNode;
276 readonly state: NavigationState;282 readonly state: NavigationState;
277}) {283}) {
284 const navigation = useNavigation();
285 const { pendingShare } = useIncomingShare();
286 const sharePresentationRef = useRef(EMPTY_INCOMING_SHARE_PRESENTATION_STATE);
278 useAgentNotificationNavigation();287 useAgentNotificationNavigation();
279 useThreadOutboxDrain();288 useThreadOutboxDrain();
280 // Presents the T3 Connect onboarding sheet after an in-session sign-in.289 // Presents the T3 Connect onboarding sheet after an in-session sign-in.
281 useConnectOnboardingNavigation();290 useConnectOnboardingNavigation();
282 // Launcher app shortcuts: routes shortcut taps and tracks opened threads.291 // Launcher app shortcuts: routes shortcut taps and tracks opened threads.
283 useAppShortcuts(props.state);292 useAppShortcuts(props.state);
293 useEffect(() => {
294 const topRouteName = props.state.routes[props.state.index]?.name;
295 const transition = transitionIncomingSharePresentation(sharePresentationRef.current, {
296 isShareSheetPresented: topRouteName === "NewTaskSheet",
297 pendingShareId: pendingShare?.id ?? null,
298 });
299 sharePresentationRef.current = transition.state;
300 if (!transition.shareIdToPresent) {
301 return;
302 }
303 navigation.navigate("NewTaskSheet", {
304 screen: "NewTask",
305 params: { incomingShareId: transition.shareIdToPresent },
306 });
307 }, [navigation, pendingShare, props.state]);
284 // Full pathname (sheets included) for keyboard-command scoping; the308 // Full pathname (sheets included) for keyboard-command scoping; the
285 // workspace layout only reacts to the underlying non-overlay route.309 // workspace layout only reacts to the underlying non-overlay route.
286 const path = getPathFromState(props.state, navigationPathConfig);310 const path = getPathFromState(props.state, navigationPathConfig);
apps/mobile/src/features/sharing/IncomingShareProvider.tsx created+310
...@@ -0,0 +1,310 @@
1import Constants from "expo-constants";
2import * as Crypto from "expo-crypto";
3import {
4 clearSharedPayloads,
5 getResolvedSharedPayloadsAsync,
6 getSharedPayloads,
7 type ResolvedSharePayload,
8 type SharePayload,
9} from "expo-sharing";
10import React, { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
11import { Alert, AppState, Platform } from "react-native";
12
13import {
14 buildIncomingShareDraft,
15 type IncomingShareDestination,
16 type IncomingShareDraft,
17} from "./incoming-share-model";
18import { IncomingShareInbox } from "./incoming-share-inbox";
19import {
20 loadIncomingShareDrafts,
21 removeIncomingShareDraft,
22 writeIncomingShareDraft,
23} from "./incoming-share-storage";
24
25type IncomingShareContextValue = {
26 readonly pendingShare: IncomingShareDraft | null;
27 readonly isLoading: boolean;
28 readonly error: Error | null;
29 readonly getShare: (shareId: string) => IncomingShareDraft | null;
30 readonly reserveShare: (shareId: string, destination: IncomingShareDestination) => Promise<void>;
31 readonly releaseShareReservation: (
32 shareId: string,
33 expectedDestination: IncomingShareDestination,
34 ) => Promise<void>;
35 readonly consumeShare: (shareId: string) => Promise<void>;
36 readonly refresh: () => Promise<void>;
37};
38
39const IncomingShareContext = React.createContext<IncomingShareContextValue | null>(null);
40
41function receiveSharingEnabled(): boolean {
42 if (Platform.OS === "android") {
43 return true;
44 }
45 if (Platform.OS !== "ios") {
46 return false;
47 }
48 return Constants.expoConfig?.extra?.iosPersonalTeamBuild !== true;
49}
50
51async function resolvedPayloadsForImages(): Promise<ReadonlyArray<ResolvedSharePayload>> {
52 try {
53 return await getResolvedSharedPayloadsAsync();
54 } catch (error) {
55 // iOS already gives the containing app a copied file:// URL, so raw
56 // payloads remain usable. Android normally resolves content:// into a
57 // private cache file; its modern File API can still read the raw URI when
58 // resolution fails.
59 console.warn("[incoming-share] could not resolve shared file metadata", error);
60 return [];
61 }
62}
63
64async function incomingShareIdForPayloads(payloads: ReadonlyArray<SharePayload>): Promise<string> {
65 const fingerprint = JSON.stringify(
66 payloads.map((payload) => ({
67 shareType: payload.shareType,
68 mimeType: payload.mimeType ?? null,
69 value: payload.value,
70 })),
71 );
72 const digest = await Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, fingerprint);
73 return `share-${digest}`;
74}
75
76async function readBase64(uri: string): Promise<string> {
77 const { File } = await import("expo-file-system");
78 return new File(uri).base64();
79}
80
81async function removeOwnedFile(uri: string): Promise<void> {
82 if (!uri.startsWith("file:")) {
83 return;
84 }
85 try {
86 const { File } = await import("expo-file-system");
87 const file = new File(uri);
88 if (file.exists) {
89 file.delete();
90 }
91 } catch (error) {
92 console.warn("[incoming-share] could not remove temporary shared file", error);
93 }
94}
95
96async function removeReplayedImagePayloadFiles(
97 payloads: ReadonlyArray<SharePayload>,
98): Promise<void> {
99 const uris = new Set<string>();
100 for (const payload of payloads) {
101 if (payload.shareType === "image") {
102 uris.add(payload.value);
103 }
104 }
105 if (uris.size === 0) {
106 return;
107 }
108 const resolvedPayloads = await resolvedPayloadsForImages();
109 for (const payload of resolvedPayloads) {
110 if (payload.shareType === "image" && payload.contentUri) {
111 uris.add(payload.contentUri);
112 }
113 }
114 await Promise.all([...uris].map(removeOwnedFile));
115}
116
117// Keep one operation queue across provider remounts (including development
118// Strict Mode remounts) so two app lifecycle notifications cannot ingest the
119// same native handoff independently.
120const incomingShareInbox = new IncomingShareInbox({
121 loadDrafts: loadIncomingShareDrafts,
122 writeDraft: writeIncomingShareDraft,
123 removeDraft: removeIncomingShareDraft,
124 getPayloads: getSharedPayloads,
125 clearPayloads: clearSharedPayloads,
126 buildDraft: async ({ payloads, id, createdAt }) => {
127 const cleanupUris = new Set<string>();
128 const resolvedPayloads = payloads.some((payload) => payload.shareType === "image")
129 ? await resolvedPayloadsForImages()
130 : [];
131 const draft = await buildIncomingShareDraft({
132 payloads,
133 resolvedPayloads,
134 fileReader: {
135 readBase64,
136 removeOwnedFile: (uri) => {
137 cleanupUris.add(uri);
138 },
139 },
140 id,
141 createdAt,
142 });
143 return {
144 draft,
145 cleanup: async () => {
146 await Promise.all([...cleanupUris].map(removeOwnedFile));
147 },
148 };
149 },
150 cleanupReplayedPayloads: removeReplayedImagePayloadFiles,
151 idForPayloads: incomingShareIdForPayloads,
152 now: () => new Date().toISOString(),
153 onClearError: (error) => {
154 console.warn("[incoming-share] could not acknowledge native payload", error);
155 },
156 onCleanupError: (error) => {
157 console.warn("[incoming-share] could not remove temporary shared file", error);
158 },
159});
160
161export function IncomingShareProvider(props: React.PropsWithChildren) {
162 const enabled = receiveSharingEnabled();
163 const [drafts, setDrafts] = useState<ReadonlyArray<IncomingShareDraft>>([]);
164 const [isLoading, setIsLoading] = useState(true);
165 const [error, setError] = useState<Error | null>(null);
166 const refreshPromiseRef = useRef<Promise<void> | null>(null);
167 const mountedRef = useRef(false);
168
169 useEffect(() => {
170 mountedRef.current = true;
171 return () => {
172 mountedRef.current = false;
173 };
174 }, []);
175
176 const refresh = useCallback(async () => {
177 if (refreshPromiseRef.current) {
178 return refreshPromiseRef.current;
179 }
180
181 const operation = (async () => {
182 try {
183 const snapshot = await incomingShareInbox.refresh({ ingestNative: enabled });
184 if (mountedRef.current) {
185 setDrafts(snapshot);
186 setError(null);
187 }
188 } catch (cause) {
189 const persisted = await incomingShareInbox
190 .refresh({ ingestNative: false })
191 .catch(() => null);
192 if (mountedRef.current) {
193 if (persisted) {
194 setDrafts(persisted);
195 }
196 setError(cause instanceof Error ? cause : new Error("Could not import shared content."));
197 }
198 }
199 })().finally(() => {
200 refreshPromiseRef.current = null;
201 });
202
203 refreshPromiseRef.current = operation;
204 return operation;
205 }, [enabled]);
206
207 useEffect(() => {
208 void refresh().finally(() => {
209 if (mountedRef.current) {
210 setIsLoading(false);
211 }
212 });
213 }, [refresh]);
214
215 const refreshOnAppActive = useEffectEvent(() => {
216 void refresh();
217 });
218
219 useEffect(() => {
220 if (!enabled) {
221 return;
222 }
223 const subscription = AppState.addEventListener("change", (state) => {
224 if (state === "active") {
225 refreshOnAppActive();
226 }
227 });
228 return () => subscription.remove();
229 }, [enabled]);
230
231 useEffect(() => {
232 if (!error) {
233 return;
234 }
235 Alert.alert("Could not import shared content", error.message, [
236 { text: "Dismiss", style: "cancel", onPress: () => setError(null) },
237 {
238 text: "Retry",
239 onPress: () => {
240 setError(null);
241 void refresh();
242 },
243 },
244 ]);
245 }, [error, refresh]);
246
247 const consumeShare = useCallback(async (shareId: string) => {
248 const snapshot = await incomingShareInbox.consume(shareId);
249 if (mountedRef.current) {
250 setDrafts(snapshot);
251 }
252 }, []);
253 const reserveShare = useCallback(
254 async (shareId: string, destination: IncomingShareDestination) => {
255 const snapshot = await incomingShareInbox.reserve(shareId, destination);
256 if (mountedRef.current) {
257 setDrafts(snapshot);
258 }
259 },
260 [],
261 );
262 const releaseShareReservation = useCallback(
263 async (shareId: string, expectedDestination: IncomingShareDestination) => {
264 const snapshot = await incomingShareInbox.releaseReservation(shareId, expectedDestination);
265 if (mountedRef.current) {
266 setDrafts(snapshot);
267 }
268 },
269 [],
270 );
271 const getShare = useCallback(
272 (shareId: string) => drafts.find((draft) => draft.id === shareId) ?? null,
273 [drafts],
274 );
275
276 const value = useMemo<IncomingShareContextValue>(
277 () => ({
278 pendingShare: drafts[0] ?? null,
279 isLoading,
280 error,
281 getShare,
282 releaseShareReservation,
283 reserveShare,
284 consumeShare,
285 refresh,
286 }),
287 [
288 consumeShare,
289 drafts,
290 error,
291 getShare,
292 isLoading,
293 refresh,
294 releaseShareReservation,
295 reserveShare,
296 ],
297 );
298
299 return (
300 <IncomingShareContext.Provider value={value}>{props.children}</IncomingShareContext.Provider>
301 );
302}
303
304export function useIncomingShare(): IncomingShareContextValue {
305 const value = React.use(IncomingShareContext);
306 if (value === null) {
307 throw new Error("useIncomingShare must be used within IncomingShareProvider.");
308 }
309 return value;
310}
apps/mobile/src/features/sharing/incoming-share-inbox.test.ts created+236
...@@ -0,0 +1,236 @@
1import { describe, expect, it, vi } from "@effect/vitest";
2import type { SharePayload } from "expo-sharing";
3
4import type { IncomingShareDraft } from "./incoming-share-model";
5import { IncomingShareInbox, type IncomingShareInboxDependencies } from "./incoming-share-inbox";
6
7const PAYLOAD: SharePayload = {
8 shareType: "text",
9 mimeType: "text/plain",
10 value: "Fix this",
11};
12
13function draft(id: string, createdAt = "2026-07-16T08:00:00.000Z"): IncomingShareDraft {
14 return {
15 schemaVersion: 1,
16 id,
17 createdAt,
18 text: PAYLOAD.value,
19 attachments: [],
20 warnings: [],
21 };
22}
23
24function deferred() {
25 let resolve!: () => void;
26 const promise = new Promise<void>((next) => {
27 resolve = next;
28 });
29 return { promise, resolve };
30}
31
32function createHarness(overrides: Partial<IncomingShareInboxDependencies> = {}) {
33 const persisted = new Map<string, IncomingShareDraft>();
34 let payloads: ReadonlyArray<SharePayload> = [PAYLOAD];
35 const dependencies: IncomingShareInboxDependencies = {
36 loadDrafts: async () => [...persisted.values()],
37 writeDraft: async (value) => {
38 persisted.set(value.id, value);
39 },
40 removeDraft: async (shareId) => {
41 persisted.delete(shareId);
42 },
43 getPayloads: () => payloads,
44 clearPayloads: () => {
45 payloads = [];
46 },
47 buildDraft: async ({ id, createdAt }) => ({
48 draft: draft(id, createdAt),
49 cleanup: async () => undefined,
50 }),
51 idForPayloads: async () => "share-stable",
52 now: () => "2026-07-16T08:00:00.000Z",
53 ...overrides,
54 };
55 return { inbox: new IncomingShareInbox(dependencies), persisted };
56}
57
58describe("IncomingShareInbox", () => {
59 it("coalesces a replay of an already-persisted native handoff", async () => {
60 const buildDraft = vi.fn(async ({ id, createdAt }) => ({
61 draft: draft(id, createdAt),
62 cleanup: async () => undefined,
63 }));
64 const cleanupReplayedPayloads = vi.fn(async () => undefined);
65 const { inbox, persisted } = createHarness({ buildDraft, cleanupReplayedPayloads });
66 persisted.set("share-stable", draft("share-stable"));
67
68 await expect(inbox.refresh({ ingestNative: true })).resolves.toEqual([draft("share-stable")]);
69 expect(buildDraft).not.toHaveBeenCalled();
70 expect(cleanupReplayedPayloads).toHaveBeenCalledWith([PAYLOAD]);
71 });
72
73 it("serializes concurrent refreshes so one native payload creates one inbox item", async () => {
74 const building = deferred();
75 const buildDraft = vi.fn(async ({ id, createdAt }) => {
76 await building.promise;
77 return { draft: draft(id, createdAt), cleanup: async () => undefined };
78 });
79 const { inbox } = createHarness({ buildDraft });
80
81 const first = inbox.refresh({ ingestNative: true });
82 const second = inbox.refresh({ ingestNative: true });
83 building.resolve();
84
85 await expect(Promise.all([first, second])).resolves.toEqual([
86 [draft("share-stable")],
87 [draft("share-stable")],
88 ]);
89 expect(buildDraft).toHaveBeenCalledTimes(1);
90 });
91
92 it("orders consumption after an in-flight refresh without restoring stale state", async () => {
93 const building = deferred();
94 const { inbox, persisted } = createHarness({
95 buildDraft: async ({ id, createdAt }) => {
96 await building.promise;
97 return { draft: draft(id, createdAt), cleanup: async () => undefined };
98 },
99 });
100
101 const refresh = inbox.refresh({ ingestNative: true });
102 const consume = inbox.consume("share-stable");
103 building.resolve();
104
105 await expect(refresh).resolves.toEqual([draft("share-stable")]);
106 await expect(consume).resolves.toEqual([]);
107 expect([...persisted.values()]).toEqual([]);
108 });
109
110 it("consumes only the addressed share when another share has identical content", async () => {
111 const { inbox, persisted } = createHarness();
112 persisted.set("share-first", draft("share-first", "2026-07-16T07:59:00.000Z"));
113 persisted.set("share-second", draft("share-second"));
114
115 await expect(inbox.refresh({ ingestNative: false })).resolves.toEqual([
116 draft("share-second"),
117 draft("share-first", "2026-07-16T07:59:00.000Z"),
118 ]);
119 await expect(inbox.consume("share-second")).resolves.toEqual([
120 draft("share-first", "2026-07-16T07:59:00.000Z"),
121 ]);
122 expect([...persisted.values()]).toEqual([draft("share-first", "2026-07-16T07:59:00.000Z")]);
123 });
124
125 it("keeps content-identical shares addressable by their own ids", async () => {
126 const { inbox, persisted } = createHarness();
127 persisted.set("share-open-flow", draft("share-open-flow", "2026-07-16T07:59:00.000Z"));
128 persisted.set("share-newer", draft("share-newer"));
129
130 await expect(inbox.refresh({ ingestNative: false })).resolves.toEqual([
131 draft("share-newer"),
132 draft("share-open-flow", "2026-07-16T07:59:00.000Z"),
133 ]);
134 });
135
136 it("does not acknowledge a supported payload when its durable write fails", async () => {
137 const clearPayloads = vi.fn();
138 const cleanup = vi.fn(async () => undefined);
139 const { inbox } = createHarness({
140 clearPayloads,
141 buildDraft: async ({ id, createdAt }) => ({
142 draft: draft(id, createdAt),
143 cleanup,
144 }),
145 writeDraft: async () => {
146 throw new Error("disk full");
147 },
148 });
149
150 await expect(inbox.refresh({ ingestNative: true })).rejects.toThrow("disk full");
151 expect(clearPayloads).not.toHaveBeenCalled();
152 expect(cleanup).not.toHaveBeenCalled();
153 });
154
155 it("durably reserves a share for one project before draft import", async () => {
156 const { inbox, persisted } = createHarness();
157 persisted.set("share-stable", draft("share-stable"));
158 const destination = { environmentId: "environment-1", projectId: "project-1" };
159
160 await expect(inbox.reserve("share-stable", destination)).resolves.toEqual([
161 { ...draft("share-stable"), destination },
162 ]);
163 expect(persisted.get("share-stable")?.destination).toEqual(destination);
164 await expect(inbox.reserve("share-stable", destination)).resolves.toEqual([
165 { ...draft("share-stable"), destination },
166 ]);
167 });
168
169 it("rejects moving a reserved share to another project", async () => {
170 const { inbox, persisted } = createHarness();
171 persisted.set("share-stable", {
172 ...draft("share-stable"),
173 destination: { environmentId: "environment-1", projectId: "project-1" },
174 });
175
176 await expect(
177 inbox.reserve("share-stable", {
178 environmentId: "environment-1",
179 projectId: "project-2",
180 }),
181 ).rejects.toThrow("already reserved");
182 expect(persisted.get("share-stable")?.destination?.projectId).toBe("project-1");
183 });
184
185 it("conditionally releases a reservation when its project is unavailable", async () => {
186 const { inbox, persisted } = createHarness();
187 const destination = { environmentId: "environment-1", projectId: "project-1" };
188 persisted.set("share-stable", { ...draft("share-stable"), destination });
189
190 await expect(inbox.releaseReservation("share-stable", destination)).resolves.toEqual([
191 draft("share-stable"),
192 ]);
193 expect(persisted.get("share-stable")?.destination).toBeUndefined();
194
195 await expect(
196 inbox.reserve("share-stable", {
197 environmentId: "environment-2",
198 projectId: "project-2",
199 }),
200 ).resolves.toEqual([
201 {
202 ...draft("share-stable"),
203 destination: { environmentId: "environment-2", projectId: "project-2" },
204 },
205 ]);
206 });
207
208 it("does not release a reservation that changed concurrently", async () => {
209 const { inbox, persisted } = createHarness();
210 persisted.set("share-stable", {
211 ...draft("share-stable"),
212 destination: { environmentId: "environment-2", projectId: "project-2" },
213 });
214
215 await expect(
216 inbox.releaseReservation("share-stable", {
217 environmentId: "environment-1",
218 projectId: "project-1",
219 }),
220 ).rejects.toThrow("reservation changed");
221 expect(persisted.get("share-stable")?.destination?.projectId).toBe("project-2");
222 });
223
224 it("treats an already-removed share as an already-released reservation", async () => {
225 const { inbox, persisted } = createHarness();
226 persisted.set("share-other", draft("share-other"));
227
228 await expect(
229 inbox.releaseReservation("share-stable", {
230 environmentId: "environment-1",
231 projectId: "project-1",
232 }),
233 ).resolves.toEqual([draft("share-other")]);
234 expect([...persisted.values()]).toEqual([draft("share-other")]);
235 });
236});
apps/mobile/src/features/sharing/incoming-share-inbox.ts created+193
...@@ -0,0 +1,193 @@
1import type { SharePayload } from "expo-sharing";
2
3import { SerializedAsyncQueue } from "../../lib/serialized-async-queue";
4import {
5 hasIncomingShareContent,
6 type IncomingShareDestination,
7 type IncomingShareDraft,
8} from "./incoming-share-model";
9
10export interface IncomingShareInboxDependencies {
11 readonly loadDrafts: () => Promise<ReadonlyArray<IncomingShareDraft>>;
12 readonly writeDraft: (draft: IncomingShareDraft) => Promise<void>;
13 readonly removeDraft: (shareId: string) => Promise<void>;
14 readonly getPayloads: () => ReadonlyArray<SharePayload>;
15 readonly clearPayloads: () => void;
16 readonly buildDraft: (input: {
17 readonly payloads: ReadonlyArray<SharePayload>;
18 readonly id: string;
19 readonly createdAt: string;
20 }) => Promise<{
21 readonly draft: IncomingShareDraft;
22 readonly cleanup: () => Promise<void>;
23 }>;
24 readonly cleanupReplayedPayloads?: (payloads: ReadonlyArray<SharePayload>) => Promise<void>;
25 readonly idForPayloads: (payloads: ReadonlyArray<SharePayload>) => Promise<string>;
26 readonly now: () => string;
27 readonly onClearError?: (error: unknown) => void;
28 readonly onCleanupError?: (error: unknown) => void;
29}
30
31export function sortAndDedupeIncomingShares(
32 drafts: ReadonlyArray<IncomingShareDraft>,
33): ReadonlyArray<IncomingShareDraft> {
34 const ids = new Set<string>();
35 return [...drafts]
36 .sort((left, right) => right.createdAt.localeCompare(left.createdAt))
37 .filter((draft) => {
38 if (ids.has(draft.id)) {
39 return false;
40 }
41 ids.add(draft.id);
42 return true;
43 });
44}
45
46/**
47 * Serializes every durable inbox mutation. This prevents a stale storage load
48 * or a foreground refresh from restoring an item after it has been consumed.
49 */
50export class IncomingShareInbox {
51 private readonly operations = new SerializedAsyncQueue();
52
53 constructor(private readonly dependencies: IncomingShareInboxDependencies) {}
54
55 private runExclusive<T>(operation: () => Promise<T>): Promise<T> {
56 return this.operations.run(operation);
57 }
58
59 private clearNativePayloads(): void {
60 try {
61 this.dependencies.clearPayloads();
62 } catch (error) {
63 this.dependencies.onClearError?.(error);
64 }
65 }
66
67 private async cleanup(operation: () => Promise<void>): Promise<void> {
68 try {
69 await operation();
70 } catch (error) {
71 this.dependencies.onCleanupError?.(error);
72 }
73 }
74
75 refresh(options: { readonly ingestNative: boolean }): Promise<ReadonlyArray<IncomingShareDraft>> {
76 return this.runExclusive(async () => {
77 const loaded = await this.dependencies.loadDrafts();
78 const persisted = sortAndDedupeIncomingShares(loaded);
79 if (!options.ingestNative) {
80 return persisted;
81 }
82
83 const payloads = this.dependencies.getPayloads();
84 if (payloads.length === 0) {
85 return persisted;
86 }
87
88 // A share extension payload remains available until the containing app
89 // acknowledges it. Use a content-derived id so a crash after the durable
90 // write but before acknowledgement reuses the same inbox item.
91 const shareId = await this.dependencies.idForPayloads(payloads);
92 if (loaded.some((draft) => draft.id === shareId)) {
93 if (this.dependencies.cleanupReplayedPayloads) {
94 await this.cleanup(() => this.dependencies.cleanupReplayedPayloads!(payloads));
95 }
96 this.clearNativePayloads();
97 return persisted;
98 }
99
100 const built = await this.dependencies.buildDraft({
101 payloads,
102 id: shareId,
103 createdAt: this.dependencies.now(),
104 });
105 const { draft } = built;
106 if (!hasIncomingShareContent(draft)) {
107 // Unsupported native payloads cannot become actionable on retry and
108 // would otherwise reopen the project picker on every foreground.
109 await this.cleanup(built.cleanup);
110 this.clearNativePayloads();
111 throw new Error(
112 draft.warnings[0] ?? "The shared content is not supported by the composer.",
113 );
114 }
115
116 // The durable inbox write is the transaction boundary. Never clear the
117 // native handoff first: a process termination must leave one recoverable
118 // copy on one side of the boundary.
119 await this.dependencies.writeDraft(draft);
120 await this.cleanup(built.cleanup);
121 this.clearNativePayloads();
122 return sortAndDedupeIncomingShares([draft, ...persisted]);
123 });
124 }
125
126 consume(shareId: string): Promise<ReadonlyArray<IncomingShareDraft>> {
127 return this.runExclusive(async () => {
128 // The stable payload-derived id already coalesces retries of the same
129 // native handoff. Payload equality cannot identify duplicate handoffs:
130 // users may intentionally share identical content more than once.
131 await this.dependencies.removeDraft(shareId);
132 return sortAndDedupeIncomingShares(await this.dependencies.loadDrafts());
133 });
134 }
135
136 reserve(
137 shareId: string,
138 destination: IncomingShareDestination,
139 ): Promise<ReadonlyArray<IncomingShareDraft>> {
140 return this.runExclusive(async () => {
141 const persisted = await this.dependencies.loadDrafts();
142 const target = persisted.find((draft) => draft.id === shareId);
143 if (!target) {
144 throw new Error("The shared content is no longer available.");
145 }
146 if (target.destination) {
147 if (
148 target.destination.environmentId !== destination.environmentId ||
149 target.destination.projectId !== destination.projectId
150 ) {
151 throw new Error("The shared content is already reserved for another project draft.");
152 }
153 return sortAndDedupeIncomingShares(persisted);
154 }
155
156 const reserved = { ...target, destination };
157 await this.dependencies.writeDraft(reserved);
158 return sortAndDedupeIncomingShares(
159 persisted.map((draft) => (draft.id === shareId ? reserved : draft)),
160 );
161 });
162 }
163
164 releaseReservation(
165 shareId: string,
166 expectedDestination: IncomingShareDestination,
167 ): Promise<ReadonlyArray<IncomingShareDraft>> {
168 return this.runExclusive(async () => {
169 const persisted = await this.dependencies.loadDrafts();
170 const target = persisted.find((draft) => draft.id === shareId);
171 if (!target) {
172 // Conditional release is idempotent: if another operation already
173 // consumed the share, no reservation remains to clean up.
174 return sortAndDedupeIncomingShares(persisted);
175 }
176 if (!target.destination) {
177 return sortAndDedupeIncomingShares(persisted);
178 }
179 if (
180 target.destination.environmentId !== expectedDestination.environmentId ||
181 target.destination.projectId !== expectedDestination.projectId
182 ) {
183 throw new Error("The shared content reservation changed before it could be released.");
184 }
185
186 const { destination: _destination, ...unreserved } = target;
187 await this.dependencies.writeDraft(unreserved);
188 return sortAndDedupeIncomingShares(
189 persisted.map((draft) => (draft.id === shareId ? unreserved : draft)),
190 );
191 });
192 }
193}
apps/mobile/src/features/sharing/incoming-share-model.test.ts created+196
...@@ -0,0 +1,196 @@
1import { describe, expect, it, vi } from "@effect/vitest";
2import {
3 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
4 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
5} from "@t3tools/contracts";
6import type { ResolvedSharePayload, SharePayload } from "expo-sharing";
7
8import { buildIncomingShareDraft, hasIncomingShareContent } from "./incoming-share-model";
9
10describe("incoming native shares", () => {
11 it("converts shared text, URLs, and images into a durable composer draft", async () => {
12 const image: SharePayload = {
13 shareType: "image",
14 value: "file:///shared/Screenshot.png",
15 mimeType: "image/png",
16 };
17 const payloads: SharePayload[] = [
18 { shareType: "text", value: "Please explain this error" },
19 { shareType: "url", value: "https://example.com/issue/1" },
20 { shareType: "text", value: "Please explain this error" },
21 image,
22 ];
23 const resolvedImage: ResolvedSharePayload = {
24 ...image,
25 contentUri: image.value,
26 contentType: "image",
27 contentMimeType: "image/png",
28 contentSize: 3,
29 originalName: "Screenshot.png",
30 };
31 const removeOwnedFile = vi.fn(() => Promise.resolve());
32
33 const result = await buildIncomingShareDraft({
34 id: "share-1",
35 createdAt: "2026-07-15T10:00:00.000Z",
36 payloads,
37 resolvedPayloads: [resolvedImage],
38 fileReader: {
39 readBase64: async () => "YWJj",
40 removeOwnedFile,
41 },
42 });
43
44 expect(result).toEqual({
45 schemaVersion: 1,
46 id: "share-1",
47 createdAt: "2026-07-15T10:00:00.000Z",
48 text: "Please explain this error\n\nhttps://example.com/issue/1",
49 attachments: [
50 {
51 id: "share-1:image:3",
52 type: "image",
53 name: "Screenshot.png",
54 mimeType: "image/png",
55 sizeBytes: 3,
56 dataUrl: "data:image/png;base64,YWJj",
57 previewUri: "data:image/png;base64,YWJj",
58 },
59 ],
60 warnings: [],
61 });
62 expect(removeOwnedFile).toHaveBeenCalledWith(image.value);
63 expect(hasIncomingShareContent(result)).toBe(true);
64 });
65
66 it("skips oversized images and releases the temporary native file", async () => {
67 const image: SharePayload = {
68 shareType: "image",
69 value: "file:///shared/huge.png",
70 mimeType: "image/png",
71 };
72 const readBase64 = vi.fn(async () => "unused");
73 const removeOwnedFile = vi.fn(() => Promise.resolve());
74
75 const result = await buildIncomingShareDraft({
76 id: "share-2",
77 createdAt: "2026-07-15T10:00:00.000Z",
78 payloads: [image],
79 resolvedPayloads: [
80 {
81 ...image,
82 contentUri: image.value,
83 contentType: "image",
84 contentMimeType: "image/png",
85 contentSize: PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1,
86 originalName: "huge.png",
87 },
88 ],
89 fileReader: { readBase64, removeOwnedFile },
90 });
91
92 expect(result.attachments).toEqual([]);
93 expect(result.warnings).toEqual(["'huge.png' exceeds the 10 MB attachment limit."]);
94 expect(readBase64).not.toHaveBeenCalled();
95 expect(removeOwnedFile).toHaveBeenCalledWith(image.value);
96 expect(hasIncomingShareContent(result)).toBe(false);
97 });
98
99 it("releases every temporary file when a share exceeds the attachment limit", async () => {
100 const payloads = Array.from({ length: PROVIDER_SEND_TURN_MAX_ATTACHMENTS + 1 }, (_, index) => ({
101 shareType: "image" as const,
102 value: `file:///shared/${index}.png`,
103 mimeType: "image/png",
104 }));
105 const removeOwnedFile = vi.fn(() => Promise.resolve());
106 const readBase64 = vi.fn(async () => "YWJj");
107
108 const result = await buildIncomingShareDraft({
109 id: "share-3",
110 createdAt: "2026-07-15T10:00:00.000Z",
111 payloads,
112 resolvedPayloads: [],
113 fileReader: { readBase64, removeOwnedFile },
114 });
115
116 expect(result.attachments).toHaveLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS);
117 expect(result.warnings).toEqual([
118 `Only the first ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} shared images were attached.`,
119 ]);
120 expect(readBase64).toHaveBeenCalledTimes(PROVIDER_SEND_TURN_MAX_ATTACHMENTS);
121 expect(removeOwnedFile).toHaveBeenCalledTimes(payloads.length);
122 });
123
124 it("maps duplicate image payloads to distinct resolved files", async () => {
125 const duplicate: SharePayload = {
126 shareType: "image",
127 value: "content://shared/screenshot",
128 mimeType: "image/png",
129 };
130 const resolvedPayloads: ResolvedSharePayload[] = [
131 {
132 ...duplicate,
133 contentUri: "file:///cache/first.png",
134 contentType: "image",
135 contentMimeType: "image/png",
136 contentSize: 3,
137 originalName: "first.png",
138 },
139 {
140 ...duplicate,
141 contentUri: "file:///cache/second.png",
142 contentType: "image",
143 contentMimeType: "image/png",
144 contentSize: 3,
145 originalName: "second.png",
146 },
147 ];
148 const readBase64 = vi.fn(async (uri: string) =>
149 uri.includes("first") ? "Zmlyc3Q=" : "c2Vjb25k",
150 );
151 const removeOwnedFile = vi.fn(async () => undefined);
152
153 const result = await buildIncomingShareDraft({
154 id: "share-duplicates",
155 createdAt: "2026-07-16T08:00:00.000Z",
156 payloads: [duplicate, duplicate],
157 resolvedPayloads,
158 fileReader: { readBase64, removeOwnedFile },
159 });
160
161 expect(readBase64.mock.calls.map(([uri]) => uri)).toEqual([
162 "file:///cache/first.png",
163 "file:///cache/second.png",
164 ]);
165 expect(result.attachments.map((attachment) => attachment.name)).toEqual([
166 "first.png",
167 "second.png",
168 ]);
169 expect(removeOwnedFile).toHaveBeenCalledWith("file:///cache/first.png");
170 expect(removeOwnedFile).toHaveBeenCalledWith("file:///cache/second.png");
171 });
172
173 it("keeps imported content when temporary-file cleanup fails", async () => {
174 const image: SharePayload = {
175 shareType: "image",
176 value: "file:///shared/screenshot.png",
177 mimeType: "image/png",
178 };
179
180 const result = await buildIncomingShareDraft({
181 id: "share-cleanup-failure",
182 createdAt: "2026-07-16T08:00:00.000Z",
183 payloads: [image],
184 resolvedPayloads: [],
185 fileReader: {
186 readBase64: async () => "YWJj",
187 removeOwnedFile: async () => {
188 throw new Error("file is busy");
189 },
190 },
191 });
192
193 expect(result.attachments).toHaveLength(1);
194 expect(result.warnings).toEqual([]);
195 });
196});
apps/mobile/src/features/sharing/incoming-share-model.ts created+217
...@@ -0,0 +1,217 @@
1import {
2 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
3 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
4} from "@t3tools/contracts";
5import * as Schema from "effect/Schema";
6import type { ResolvedSharePayload, SharePayload } from "expo-sharing";
7
8import { DraftComposerImageAttachmentSchema } from "../../lib/composer-image-schema";
9import type { DraftComposerImageAttachment } from "../../lib/composerImages";
10import { estimateBase64ByteSize } from "../../lib/base64";
11
12export interface IncomingShareDraft {
13 readonly schemaVersion: 1;
14 readonly id: string;
15 readonly createdAt: string;
16 readonly destination?: IncomingShareDestination;
17 readonly text: string;
18 readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;
19 readonly warnings: ReadonlyArray<string>;
20}
21
22export interface IncomingShareDestination {
23 readonly environmentId: string;
24 readonly projectId: string;
25}
26
27const IncomingShareDestinationSchema = Schema.Struct({
28 environmentId: Schema.String,
29 projectId: Schema.String,
30});
31
32export const IncomingShareDraftSchema = Schema.Struct({
33 schemaVersion: Schema.Literal(1),
34 id: Schema.String,
35 createdAt: Schema.String,
36 destination: Schema.optional(IncomingShareDestinationSchema),
37 text: Schema.String,
38 attachments: Schema.Array(DraftComposerImageAttachmentSchema),
39 warnings: Schema.Array(Schema.String),
40});
41
42const decodeIncomingShareDraftSync = Schema.decodeUnknownSync(IncomingShareDraftSchema);
43
44export function decodeIncomingShareDraft(value: unknown): IncomingShareDraft {
45 return decodeIncomingShareDraftSync(value);
46}
47
48export interface IncomingShareFileReader {
49 readonly readBase64: (uri: string) => Promise<string>;
50 readonly removeOwnedFile: (uri: string) => Promise<void> | void;
51}
52
53function sharedText(payloads: ReadonlyArray<SharePayload>): string {
54 const seen = new Set<string>();
55 const values: string[] = [];
56 for (const payload of payloads) {
57 if (payload.shareType !== "text" && payload.shareType !== "url") {
58 continue;
59 }
60 const value = payload.value.trim();
61 if (!value || seen.has(value)) {
62 continue;
63 }
64 seen.add(value);
65 values.push(value);
66 }
67 return values.join("\n\n");
68}
69
70function resolvedImageFor(
71 payload: SharePayload,
72 index: number,
73 resolvedPayloads: ReadonlyArray<ResolvedSharePayload>,
74 consumedIndexes: Set<number>,
75): ResolvedSharePayload | undefined {
76 const sameIndex = resolvedPayloads[index];
77 if (
78 !consumedIndexes.has(index) &&
79 sameIndex?.shareType === payload.shareType &&
80 sameIndex.value === payload.value
81 ) {
82 consumedIndexes.add(index);
83 return sameIndex;
84 }
85 const matchingIndex = resolvedPayloads.findIndex(
86 (candidate, candidateIndex) =>
87 !consumedIndexes.has(candidateIndex) &&
88 candidate.shareType === payload.shareType &&
89 candidate.value === payload.value,
90 );
91 if (matchingIndex < 0) {
92 return undefined;
93 }
94 consumedIndexes.add(matchingIndex);
95 return resolvedPayloads[matchingIndex];
96}
97
98async function releaseOwnedFiles(
99 fileReader: IncomingShareFileReader,
100 uris: ReadonlyArray<string | undefined>,
101): Promise<void> {
102 for (const uri of new Set(uris.filter((candidate): candidate is string => Boolean(candidate)))) {
103 try {
104 await fileReader.removeOwnedFile(uri);
105 } catch {
106 // Temporary-file cleanup is best-effort and must never discard content
107 // that was successfully converted into a durable composer attachment.
108 }
109 }
110}
111
112function fallbackName(uri: string, index: number, mimeType: string): string {
113 try {
114 const pathName = new URL(uri).pathname.split("/").findLast((segment) => segment.length > 0);
115 if (pathName) {
116 return decodeURIComponent(pathName);
117 }
118 } catch {
119 // Fall through to a deterministic attachment name.
120 }
121 const extension = mimeType.split("/")[1]?.replace(/[^a-z0-9.+-]/gi, "") || "png";
122 return `shared-image-${index + 1}.${extension}`;
123}
124
125export async function buildIncomingShareDraft(input: {
126 readonly payloads: ReadonlyArray<SharePayload>;
127 readonly resolvedPayloads: ReadonlyArray<ResolvedSharePayload>;
128 readonly fileReader: IncomingShareFileReader;
129 readonly id: string;
130 readonly createdAt: string;
131}): Promise<IncomingShareDraft> {
132 const attachments: DraftComposerImageAttachment[] = [];
133 const warnings: string[] = [];
134 const consumedResolvedPayloadIndexes = new Set<number>();
135 let warnedAttachmentLimit = false;
136
137 for (const [index, payload] of input.payloads.entries()) {
138 if (payload.shareType !== "image") {
139 continue;
140 }
141 const resolved = resolvedImageFor(
142 payload,
143 index,
144 input.resolvedPayloads,
145 consumedResolvedPayloadIndexes,
146 );
147 const uri = resolved?.contentUri ?? payload.value;
148 if (attachments.length >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {
149 if (!warnedAttachmentLimit) {
150 warnings.push(
151 `Only the first ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} shared images were attached.`,
152 );
153 warnedAttachmentLimit = true;
154 }
155 await releaseOwnedFiles(input.fileReader, [uri, payload.value]);
156 continue;
157 }
158
159 const mimeType = (resolved?.contentMimeType ?? payload.mimeType ?? "image/png").toLowerCase();
160 if (!uri || !mimeType.startsWith("image/")) {
161 warnings.push("One shared item was not a supported image.");
162 await releaseOwnedFiles(input.fileReader, [uri, payload.value]);
163 continue;
164 }
165 if (
166 resolved?.contentSize !== null &&
167 resolved?.contentSize !== undefined &&
168 resolved.contentSize > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES
169 ) {
170 warnings.push(
171 `'${resolved.originalName ?? fallbackName(uri, index, mimeType)}' exceeds the 10 MB attachment limit.`,
172 );
173 await releaseOwnedFiles(input.fileReader, [uri, payload.value]);
174 continue;
175 }
176
177 try {
178 const base64 = await input.fileReader.readBase64(uri);
179 const sizeBytes = resolved?.contentSize ?? estimateBase64ByteSize(base64);
180 if (sizeBytes <= 0 || sizeBytes > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) {
181 warnings.push(
182 `'${resolved?.originalName ?? fallbackName(uri, index, mimeType)}' exceeds the 10 MB attachment limit.`,
183 );
184 continue;
185 }
186 const dataUrl = `data:${mimeType};base64,${base64}`;
187 attachments.push({
188 id: `${input.id}:image:${index}`,
189 type: "image",
190 name: resolved?.originalName ?? fallbackName(uri, index, mimeType),
191 mimeType,
192 sizeBytes,
193 dataUrl,
194 // The share provider's file is temporary. A data-backed preview keeps
195 // the composer valid after its source file and App Group entry are gone.
196 previewUri: dataUrl,
197 });
198 } catch {
199 warnings.push(`Could not read '${fallbackName(uri, index, mimeType)}'.`);
200 } finally {
201 await releaseOwnedFiles(input.fileReader, [uri, payload.value]);
202 }
203 }
204
205 return {
206 schemaVersion: 1,
207 id: input.id,
208 createdAt: input.createdAt,
209 text: sharedText(input.payloads),
210 attachments,
211 warnings,
212 };
213}
214
215export function hasIncomingShareContent(draft: IncomingShareDraft): boolean {
216 return draft.text.trim().length > 0 || draft.attachments.length > 0;
217}
apps/mobile/src/features/sharing/incoming-share-presentation.test.ts created+92
...@@ -0,0 +1,92 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import {
4 EMPTY_INCOMING_SHARE_PRESENTATION_STATE,
5 transitionIncomingSharePresentation,
6} from "./incoming-share-presentation";
7
8describe("incoming share presentation", () => {
9 it("does not reopen a dismissed share when refresh returns a new object for the same id", () => {
10 const presented = transitionIncomingSharePresentation(EMPTY_INCOMING_SHARE_PRESENTATION_STATE, {
11 isShareSheetPresented: false,
12 pendingShareId: "share-1",
13 });
14 expect(presented.shareIdToPresent).toBe("share-1");
15
16 const whilePresented = transitionIncomingSharePresentation(presented.state, {
17 isShareSheetPresented: true,
18 pendingShareId: "share-1",
19 });
20 expect(whilePresented).toEqual({ state: presented.state, shareIdToPresent: null });
21
22 const dismissed = transitionIncomingSharePresentation(whilePresented.state, {
23 isShareSheetPresented: false,
24 pendingShareId: "share-1",
25 });
26 expect(dismissed.state.dismissedShareId).toBe("share-1");
27
28 expect(
29 transitionIncomingSharePresentation(dismissed.state, {
30 isShareSheetPresented: false,
31 pendingShareId: "share-1",
32 }),
33 ).toEqual({ state: dismissed.state, shareIdToPresent: null });
34 });
35
36 it("presents the next queued share immediately after the previous sheet closes", () => {
37 const first = transitionIncomingSharePresentation(EMPTY_INCOMING_SHARE_PRESENTATION_STATE, {
38 isShareSheetPresented: false,
39 pendingShareId: "share-1",
40 });
41
42 const next = transitionIncomingSharePresentation(first.state, {
43 isShareSheetPresented: false,
44 pendingShareId: "share-2",
45 });
46 expect(next.shareIdToPresent).toBe("share-2");
47 expect(next.state).toEqual({ presentedShareId: "share-2", dismissedShareId: null });
48 });
49
50 it("forgets dismissal after consumption so a later handoff may reuse the id", () => {
51 const dismissed = {
52 presentedShareId: null,
53 dismissedShareId: "share-1",
54 };
55 const consumed = transitionIncomingSharePresentation(dismissed, {
56 isShareSheetPresented: false,
57 pendingShareId: null,
58 });
59 expect(consumed.state).toEqual(EMPTY_INCOMING_SHARE_PRESENTATION_STATE);
60
61 expect(
62 transitionIncomingSharePresentation(consumed.state, {
63 isShareSheetPresented: false,
64 pendingShareId: "share-1",
65 }).shareIdToPresent,
66 ).toBe("share-1");
67 });
68
69 it("forgets a consumed presentation while its sheet is still mounted", () => {
70 const presented = {
71 presentedShareId: "share-1",
72 dismissedShareId: null,
73 };
74 const consumed = transitionIncomingSharePresentation(presented, {
75 isShareSheetPresented: true,
76 pendingShareId: null,
77 });
78 expect(consumed.state).toEqual(EMPTY_INCOMING_SHARE_PRESENTATION_STATE);
79
80 const replacementWhileOpen = transitionIncomingSharePresentation(consumed.state, {
81 isShareSheetPresented: true,
82 pendingShareId: "share-1",
83 });
84 expect(replacementWhileOpen.shareIdToPresent).toBeNull();
85 expect(
86 transitionIncomingSharePresentation(replacementWhileOpen.state, {
87 isShareSheetPresented: false,
88 pendingShareId: "share-1",
89 }).shareIdToPresent,
90 ).toBe("share-1");
91 });
92});
apps/mobile/src/features/sharing/incoming-share-presentation.ts created+71
...@@ -0,0 +1,71 @@
1export interface IncomingSharePresentationState {
2 readonly presentedShareId: string | null;
3 readonly dismissedShareId: string | null;
4}
5
6export interface IncomingSharePresentationTransition {
7 readonly state: IncomingSharePresentationState;
8 readonly shareIdToPresent: string | null;
9}
10
11export const EMPTY_INCOMING_SHARE_PRESENTATION_STATE: IncomingSharePresentationState = {
12 presentedShareId: null,
13 dismissedShareId: null,
14};
15
16/**
17 * Tracks presentation by durable share id rather than object identity. A user
18 * dismissal suppresses only that inbox item until it is consumed or replaced.
19 */
20export function transitionIncomingSharePresentation(
21 state: IncomingSharePresentationState,
22 input: {
23 readonly isShareSheetPresented: boolean;
24 readonly pendingShareId: string | null;
25 },
26): IncomingSharePresentationTransition {
27 if (input.isShareSheetPresented) {
28 if (state.presentedShareId !== null && input.pendingShareId !== state.presentedShareId) {
29 // Consumption may happen while the sheet remains mounted. Forget the
30 // old presentation immediately so a later handoff may reuse its id.
31 return {
32 state: EMPTY_INCOMING_SHARE_PRESENTATION_STATE,
33 shareIdToPresent: null,
34 };
35 }
36 return { state, shareIdToPresent: null };
37 }
38
39 let nextState = state;
40 if (state.presentedShareId !== null) {
41 if (input.pendingShareId === state.presentedShareId) {
42 return {
43 state: {
44 presentedShareId: null,
45 dismissedShareId: state.presentedShareId,
46 },
47 shareIdToPresent: null,
48 };
49 }
50 nextState = { ...state, presentedShareId: null };
51 }
52
53 if (input.pendingShareId === null) {
54 return {
55 state: EMPTY_INCOMING_SHARE_PRESENTATION_STATE,
56 shareIdToPresent: null,
57 };
58 }
59
60 if (nextState.dismissedShareId === input.pendingShareId) {
61 return { state: nextState, shareIdToPresent: null };
62 }
63
64 return {
65 state: {
66 presentedShareId: input.pendingShareId,
67 dismissedShareId: null,
68 },
69 shareIdToPresent: input.pendingShareId,
70 };
71}
apps/mobile/src/features/sharing/incoming-share-storage.ts created+80
...@@ -0,0 +1,80 @@
1import * as Schema from "effect/Schema";
2
3import { decodeIncomingShareDraft, type IncomingShareDraft } from "./incoming-share-model";
4
5const INCOMING_SHARE_DIRECTORY = "incoming-shares";
6
7export class IncomingShareStorageError extends Schema.TaggedErrorClass<IncomingShareStorageError>()(
8 "IncomingShareStorageError",
9 {
10 operation: Schema.Literals(["load", "write", "remove"]),
11 shareId: Schema.NullOr(Schema.String),
12 cause: Schema.Defect(),
13 },
14) {
15 override get message(): string {
16 return `Incoming share storage operation ${this.operation} failed for ${this.shareId ?? "unknown"}.`;
17 }
18}
19
20function fileName(shareId: string): string {
21 return `${encodeURIComponent(shareId)}.json`;
22}
23
24async function getDirectory() {
25 const { Directory, Paths } = await import("expo-file-system");
26 const directory = new Directory(Paths.document, INCOMING_SHARE_DIRECTORY);
27 directory.create({ idempotent: true, intermediates: true });
28 return directory;
29}
30
31async function getFile(shareId: string) {
32 const { File } = await import("expo-file-system");
33 return new File(await getDirectory(), fileName(shareId));
34}
35
36export async function loadIncomingShareDrafts(): Promise<ReadonlyArray<IncomingShareDraft>> {
37 try {
38 const { File } = await import("expo-file-system");
39 const drafts: IncomingShareDraft[] = [];
40 for (const entry of (await getDirectory()).list()) {
41 if (!(entry instanceof File) || !entry.name.endsWith(".json")) {
42 continue;
43 }
44 try {
45 drafts.push(decodeIncomingShareDraft(JSON.parse(await entry.text()) as unknown));
46 } catch (cause) {
47 console.warn(
48 "[incoming-share] ignored invalid persisted share",
49 new IncomingShareStorageError({ operation: "load", shareId: null, cause }),
50 );
51 }
52 }
53 return drafts.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
54 } catch (cause) {
55 throw new IncomingShareStorageError({ operation: "load", shareId: null, cause });
56 }
57}
58
59export async function writeIncomingShareDraft(draft: IncomingShareDraft): Promise<void> {
60 try {
61 const file = await getFile(draft.id);
62 if (!file.exists) {
63 file.create({ intermediates: true, overwrite: true });
64 }
65 file.write(JSON.stringify(draft));
66 } catch (cause) {
67 throw new IncomingShareStorageError({ operation: "write", shareId: draft.id, cause });
68 }
69}
70
71export async function removeIncomingShareDraft(shareId: string): Promise<void> {
72 try {
73 const file = await getFile(shareId);
74 if (file.exists) {
75 file.delete();
76 }
77 } catch (cause) {
78 throw new IncomingShareStorageError({ operation: "remove", shareId, cause });
79 }
80}
apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx+4
...@@ -9,6 +9,7 @@ type NewTaskDraftRouteParams = {...@@ -9,6 +9,7 @@ type NewTaskDraftRouteParams = {
9 readonly projectId?: string | string[];9 readonly projectId?: string | string[];
10 readonly title?: string | string[];10 readonly title?: string | string[];
11 readonly pendingTaskId?: string | string[];11 readonly pendingTaskId?: string | string[];
12 readonly incomingShareId?: string | string[];
12};13};
1314
14export function NewTaskDraftRouteScreen({ route }: StaticScreenProps<NewTaskDraftRouteParams>) {15export function NewTaskDraftRouteScreen({ route }: StaticScreenProps<NewTaskDraftRouteParams>) {
...@@ -36,6 +37,9 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps<NewTaskDraf...@@ -36,6 +37,9 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps<NewTaskDraf
36 />37 />
37 <NewTaskDraftScreen38 <NewTaskDraftScreen
38 initialProjectRef={initialProjectRef}39 initialProjectRef={initialProjectRef}
40 incomingShareId={
41 Array.isArray(params.incomingShareId) ? params.incomingShareId[0] : params.incomingShareId
42 }
39 pendingTaskId={43 pendingTaskId={
40 Array.isArray(params.pendingTaskId) ? params.pendingTaskId[0] : params.pendingTaskId44 Array.isArray(params.pendingTaskId) ? params.pendingTaskId[0] : params.pendingTaskId
41 }45 }
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+337-11
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { NativeStackScreenOptions } from "../../native/StackHeader";1import { NativeStackScreenOptions } from "../../native/StackHeader";
2import { StackActions, useNavigation } from "@react-navigation/native";2import { StackActions, useNavigation, usePreventRemove } from "@react-navigation/native";
3import { useCallback, useEffect, useMemo, useRef, useState } from "react";3import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4import { Alert, InteractionManager, Platform, View, useColorScheme } from "react-native";4import { Alert, InteractionManager, Platform, View, useColorScheme } from "react-native";
5import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller";5import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller";
...@@ -35,7 +35,13 @@ import {...@@ -35,7 +35,13 @@ import {
35 resolveProviderOptionDescriptors,35 resolveProviderOptionDescriptors,
36} from "../../lib/providerOptions";36} from "../../lib/providerOptions";
37import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";37import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
38import { getComposerDraftSnapshot } from "../../state/use-composer-drafts";38import {
39 clearComposerDraftContent,
40 getComposerDraftSnapshot,
41 mergeComposerDraftContent,
42 restoreComposerDraftSnapshot,
43 type ComposerDraft,
44} from "../../state/use-composer-drafts";
39import { useProjects } from "../../state/entities";45import { useProjects } from "../../state/entities";
40import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn";46import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn";
41import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";47import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
...@@ -43,6 +49,7 @@ import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../sta...@@ -43,6 +49,7 @@ import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../sta
43import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry";49import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry";
44import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider";50import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider";
45import { useCreateProjectThread } from "./use-project-actions";51import { useCreateProjectThread } from "./use-project-actions";
52import { useIncomingShare } from "../sharing/IncomingShareProvider";
4653
47function formatWorkspaceLabel(input: {54function formatWorkspaceLabel(input: {
48 readonly workspaceMode: string;55 readonly workspaceMode: string;
...@@ -63,11 +70,20 @@ export function NewTaskDraftScreen(props: {...@@ -63,11 +70,20 @@ export function NewTaskDraftScreen(props: {
63 };70 };
64 /** Queued outbox message id when editing an existing pending task. */71 /** Queued outbox message id when editing an existing pending task. */
65 readonly pendingTaskId?: string;72 readonly pendingTaskId?: string;
73 /** Durable native share inbox item to merge into this project draft. */
74 readonly incomingShareId?: string;
66}) {75}) {
67 const projects = useProjects();76 const projects = useProjects();
68 const createProjectThread = useCreateProjectThread();77 const createProjectThread = useCreateProjectThread();
69 const flow = useNewTaskFlow();78 const flow = useNewTaskFlow();
70 const navigation = useNavigation();79 const navigation = useNavigation();
80 const {
81 consumeShare,
82 getShare,
83 isLoading: isIncomingShareInboxLoading,
84 releaseShareReservation,
85 reserveShare,
86 } = useIncomingShare();
71 const insets = useSafeAreaInsets();87 const insets = useSafeAreaInsets();
72 const colorScheme = useColorScheme();88 const colorScheme = useColorScheme();
73 const isKeyboardVisible = useKeyboardState((state) => state.isVisible);89 const isKeyboardVisible = useKeyboardState((state) => state.isVisible);
...@@ -82,10 +98,94 @@ export function NewTaskDraftScreen(props: {...@@ -82,10 +98,94 @@ export function NewTaskDraftScreen(props: {
82 const promptInputRef = useRef<ComposerEditorHandle>(null);98 const promptInputRef = useRef<ComposerEditorHandle>(null);
83 const loadedBranchesProjectKeyRef = useRef<string | null>(null);99 const loadedBranchesProjectKeyRef = useRef<string | null>(null);
84 const [isComposerFocused, setIsComposerFocused] = useState(false);100 const [isComposerFocused, setIsComposerFocused] = useState(false);
101 const [importingShareKey, setImportingShareKey] = useState<string | null>(null);
102 const [isCancellingShareImport, setIsCancellingShareImport] = useState(false);
103 const [cancelledIncomingShareId, setCancelledIncomingShareId] = useState<string | null>(null);
104 const [isReturningToProjectPicker, setIsReturningToProjectPicker] = useState(false);
105 const [shareImportAttempt, setShareImportAttempt] = useState(0);
106 const startedShareImportKeyRef = useRef<string | null>(null);
107 const cancellingShareImportKeyRef = useRef<string | null>(null);
108 const shareImportDraftBackupRef = useRef(new Map<string, ComposerDraft>());
109 const activeShareImportTokenRef = useRef<symbol | null>(null);
110 const shareImportMountedRef = useRef(true);
111 const latestDraftKeyRef = useRef(flow.draftKey);
112 const latestIncomingShareIdRef = useRef(props.incomingShareId);
113 latestDraftKeyRef.current = flow.draftKey;
114 latestIncomingShareIdRef.current = props.incomingShareId;
115 const isImportingShare = importingShareKey !== null;
116 const alertedUnavailableIncomingShareIdRef = useRef<string | null>(null);
117 const incomingShare = props.incomingShareId ? getShare(props.incomingShareId) : null;
118 const requestedInitialProjectAvailable = Boolean(
119 props.initialProjectRef?.environmentId &&
120 props.initialProjectRef.projectId &&
121 projects.some(
122 (project) =>
123 project.environmentId === props.initialProjectRef?.environmentId &&
124 project.id === props.initialProjectRef?.projectId,
125 ),
126 );
127 const isProjectPickerReturnActive =
128 isReturningToProjectPicker && !requestedInitialProjectAvailable;
129 const isIncomingShareTransferPending = Boolean(
130 incomingShare && cancelledIncomingShareId !== props.incomingShareId,
131 );
132 usePreventRemove(
133 (isIncomingShareTransferPending && !isProjectPickerReturnActive) || isCancellingShareImport,
134 () => undefined,
135 );
136 const hasImportedIncomingShare = Boolean(
137 props.incomingShareId &&
138 flow.draftKey &&
139 getComposerDraftSnapshot(flow.draftKey).importedShareIds?.includes(props.incomingShareId),
140 );
141 const isIncomingShareUnavailable = Boolean(
142 props.incomingShareId &&
143 !isIncomingShareInboxLoading &&
144 !incomingShare &&
145 !hasImportedIncomingShare,
146 );
147 const isIncomingShareReady =
148 !props.incomingShareId ||
149 (hasImportedIncomingShare && !incomingShare) ||
150 isIncomingShareUnavailable;
85 const appliedInitialProjectKeyRef = useRef<string | null>(null);151 const appliedInitialProjectKeyRef = useRef<string | null>(null);
86 useEffect(() => {152 useEffect(() => {
153 if (cancelledIncomingShareId === props.incomingShareId) {
154 navigation.goBack();
155 }
156 }, [cancelledIncomingShareId, navigation, props.incomingShareId]);
157 useEffect(() => {
158 if (!isReturningToProjectPicker) {
159 return;
160 }
161 if (requestedInitialProjectAvailable) {
162 setIsReturningToProjectPicker(false);
163 return;
164 }
165 // Let usePreventRemove commit its disabled state before replacing this
166 // route, otherwise the transfer guard can swallow the fallback action.
167 const frame = requestAnimationFrame(() => {
168 navigation.dispatch(
169 StackActions.replace("NewTask", { incomingShareId: props.incomingShareId }),
170 );
171 });
172 return () => cancelAnimationFrame(frame);
173 }, [
174 isReturningToProjectPicker,
175 navigation,
176 props.incomingShareId,
177 requestedInitialProjectAvailable,
178 ]);
179 useEffect(() => {
180 if (!shareImportMountedRef.current) {
181 startedShareImportKeyRef.current = null;
182 }
183 shareImportMountedRef.current = true;
87 return () => {184 return () => {
88 appliedInitialProjectKeyRef.current = null;185 appliedInitialProjectKeyRef.current = null;
186 shareImportMountedRef.current = false;
187 activeShareImportTokenRef.current = null;
188 cancellingShareImportKeyRef.current = null;
89 };189 };
90 }, []);190 }, []);
91191
...@@ -163,6 +263,14 @@ export function NewTaskDraftScreen(props: {...@@ -163,6 +263,14 @@ export function NewTaskDraftScreen(props: {
163 setProject(directProject);263 setProject(directProject);
164 return;264 return;
165 }265 }
266
267 if (projects.length > 0) {
268 // Never fall through to the flow provider's temporary first-project
269 // default. Return to the picker with the share id intact so the user
270 // can choose an available destination.
271 setIsReturningToProjectPicker(true);
272 }
273 return;
166 }274 }
167275
168 if (selectedProject) {276 if (selectedProject) {
...@@ -179,6 +287,7 @@ export function NewTaskDraftScreen(props: {...@@ -179,6 +287,7 @@ export function NewTaskDraftScreen(props: {
179 logicalProjects,287 logicalProjects,
180 projects,288 projects,
181 props.initialProjectRef,289 props.initialProjectRef,
290 props.incomingShareId,
182 props.pendingTaskId,291 props.pendingTaskId,
183 navigation,292 navigation,
184 selectedProject,293 selectedProject,
...@@ -198,6 +307,205 @@ export function NewTaskDraftScreen(props: {...@@ -198,6 +307,205 @@ export function NewTaskDraftScreen(props: {
198 void flow.loadBranches();307 void flow.loadBranches();
199 }, [flow.loadBranches, selectedProject]);308 }, [flow.loadBranches, selectedProject]);
200309
310 useEffect(() => {
311 const shareId = props.incomingShareId;
312 const draftKey = flow.draftKey;
313 const destinationProject = selectedProject;
314 const initialEnvironmentId = props.initialProjectRef?.environmentId;
315 const initialProjectId = props.initialProjectRef?.projectId;
316 const selectedProjectMatchesRoute =
317 !initialEnvironmentId ||
318 !initialProjectId ||
319 (destinationProject?.environmentId === initialEnvironmentId &&
320 destinationProject.id === initialProjectId);
321 if (
322 !shareId ||
323 !draftKey ||
324 !destinationProject ||
325 !selectedProjectMatchesRoute ||
326 cancelledIncomingShareId === shareId
327 ) {
328 return;
329 }
330 const importKey = `${shareId}:${draftKey}`;
331 if (
332 startedShareImportKeyRef.current === importKey ||
333 cancellingShareImportKeyRef.current === importKey
334 ) {
335 return;
336 }
337
338 if (!incomingShare) {
339 if (isIncomingShareUnavailable && alertedUnavailableIncomingShareIdRef.current !== shareId) {
340 alertedUnavailableIncomingShareIdRef.current = shareId;
341 Alert.alert(
342 "Shared content unavailable",
343 "The shared content is no longer in the inbox. You can continue editing this task draft.",
344 );
345 }
346 return;
347 }
348
349 if (alertedUnavailableIncomingShareIdRef.current === shareId) {
350 alertedUnavailableIncomingShareIdRef.current = null;
351 }
352 startedShareImportKeyRef.current = importKey;
353 const draftBackup =
354 shareImportDraftBackupRef.current.get(importKey) ?? getComposerDraftSnapshot(draftKey);
355 shareImportDraftBackupRef.current.set(importKey, draftBackup);
356 const importToken = Symbol(importKey);
357 let didReserveShare = false;
358 let needsDraftRestore = false;
359 activeShareImportTokenRef.current = importToken;
360 setImportingShareKey(importKey);
361 void (async () => {
362 await reserveShare(shareId, {
363 environmentId: String(destinationProject.environmentId),
364 projectId: String(destinationProject.id),
365 });
366 didReserveShare = true;
367 if (
368 !shareImportMountedRef.current ||
369 activeShareImportTokenRef.current !== importToken ||
370 latestDraftKeyRef.current !== draftKey ||
371 latestIncomingShareIdRef.current !== shareId
372 ) {
373 return;
374 }
375 needsDraftRestore = true;
376 const { skippedAttachmentCount } = await mergeComposerDraftContent(draftKey, {
377 text: incomingShare.text,
378 attachments: incomingShare.attachments,
379 sourceShareId: shareId,
380 });
381 if (
382 !shareImportMountedRef.current ||
383 activeShareImportTokenRef.current !== importToken ||
384 latestDraftKeyRef.current !== draftKey ||
385 latestIncomingShareIdRef.current !== shareId
386 ) {
387 // The durable reservation makes an interrupted transfer resume only
388 // in this project instead of copying into a second project draft.
389 return;
390 }
391 await consumeShare(shareId);
392 if (!shareImportMountedRef.current || activeShareImportTokenRef.current !== importToken) {
393 return;
394 }
395 const warnings = [...incomingShare.warnings];
396 if (skippedAttachmentCount > 0) {
397 warnings.push(
398 `${skippedAttachmentCount} shared image${skippedAttachmentCount === 1 ? " was" : "s were"} skipped because this draft reached the attachment limit.`,
399 );
400 }
401 if (warnings.length > 0) {
402 Alert.alert("Some shared content was skipped", warnings.join("\n"));
403 }
404 shareImportDraftBackupRef.current.delete(importKey);
405 })()
406 .catch((error) => {
407 if (!shareImportMountedRef.current || activeShareImportTokenRef.current !== importToken) {
408 return;
409 }
410 Alert.alert(
411 "Could not import shared content",
412 error instanceof Error ? error.message : "The shared content could not be saved.",
413 [
414 {
415 text: "Cancel import",
416 style: "cancel",
417 onPress: () => {
418 const cancelImport = async (): Promise<void> => {
419 if (!shareImportMountedRef.current) {
420 return;
421 }
422 // Latch synchronously before restoring the draft. The
423 // restore publishes atom state and can re-run the import
424 // effect before React commits the cancelling state update.
425 cancellingShareImportKeyRef.current = importKey;
426 setIsCancellingShareImport(true);
427 try {
428 if (needsDraftRestore) {
429 await restoreComposerDraftSnapshot(draftKey, draftBackup);
430 needsDraftRestore = false;
431 }
432 if (didReserveShare) {
433 await releaseShareReservation(shareId, {
434 environmentId: String(destinationProject.environmentId),
435 projectId: String(destinationProject.id),
436 });
437 }
438 shareImportDraftBackupRef.current.delete(importKey);
439 if (shareImportMountedRef.current) {
440 setIsCancellingShareImport(false);
441 setCancelledIncomingShareId(shareId);
442 }
443 } catch (cancelError) {
444 if (!shareImportMountedRef.current) {
445 return;
446 }
447 Alert.alert(
448 "Could not cancel import",
449 cancelError instanceof Error
450 ? cancelError.message
451 : "The shared content could not be restored safely.",
452 [
453 {
454 text: "Retry import",
455 onPress: () => {
456 cancellingShareImportKeyRef.current = null;
457 setIsCancellingShareImport(false);
458 setShareImportAttempt((attempt) => attempt + 1);
459 },
460 },
461 {
462 text: "Retry cancel",
463 onPress: () => void cancelImport(),
464 },
465 ],
466 { cancelable: false },
467 );
468 }
469 };
470 void cancelImport();
471 },
472 },
473 {
474 text: "Retry",
475 onPress: () => setShareImportAttempt((attempt) => attempt + 1),
476 },
477 ],
478 { cancelable: false },
479 );
480 })
481 .finally(() => {
482 if (startedShareImportKeyRef.current === importKey) {
483 // Every terminal path, including an invalidated operation, must
484 // release the synchronous start latch so this transfer can retry.
485 startedShareImportKeyRef.current = null;
486 }
487 if (shareImportMountedRef.current && activeShareImportTokenRef.current === importToken) {
488 activeShareImportTokenRef.current = null;
489 setImportingShareKey(null);
490 }
491 });
492 }, [
493 consumeShare,
494 cancelledIncomingShareId,
495 flow.draftKey,
496 hasImportedIncomingShare,
497 incomingShare,
498 isIncomingShareInboxLoading,
499 isIncomingShareUnavailable,
500 props.incomingShareId,
501 props.initialProjectRef?.environmentId,
502 props.initialProjectRef?.projectId,
503 releaseShareReservation,
504 reserveShare,
505 selectedProject,
506 shareImportAttempt,
507 ]);
508
201 useEffect(() => {509 useEffect(() => {
202 // Android starts with the collapsed composer pill (like an open thread)510 // Android starts with the collapsed composer pill (like an open thread)
203 // and only expands/focuses when tapped.511 // and only expands/focuses when tapped.
...@@ -223,10 +531,11 @@ export function NewTaskDraftScreen(props: {...@@ -223,10 +531,11 @@ export function NewTaskDraftScreen(props: {
223 flow.environments.map((environment) => ({531 flow.environments.map((environment) => ({
224 id: `environment:${environment.environmentId}`,532 id: `environment:${environment.environmentId}`,
225 title: environment.environmentLabel,533 title: environment.environmentLabel,
534 attributes: isIncomingShareTransferPending ? { disabled: true } : undefined,
226 state:535 state:
227 flow.selectedEnvironmentId === environment.environmentId ? ("on" as const) : undefined,536 flow.selectedEnvironmentId === environment.environmentId ? ("on" as const) : undefined,
228 })),537 })),
229 [flow.environments, flow.selectedEnvironmentId],538 [flow.environments, flow.selectedEnvironmentId, isIncomingShareTransferPending],
230 );539 );
231540
232 const modelMenuActions = useMemo(541 const modelMenuActions = useMemo(
...@@ -391,20 +700,23 @@ export function NewTaskDraftScreen(props: {...@@ -391,20 +700,23 @@ export function NewTaskDraftScreen(props: {
391 [currentBranchName, flow.selectedBranchName, flow.workspaceMode],700 [currentBranchName, flow.selectedBranchName, flow.workspaceMode],
392 );701 );
393 function handleModelMenuAction(event: string) {702 function handleModelMenuAction(event: string) {
394 if (!event.startsWith("model:")) {703 if (isIncomingShareTransferPending || !event.startsWith("model:")) {
395 return;704 return;
396 }705 }
397 flow.setSelectedModelKey(event.slice("model:".length));706 flow.setSelectedModelKey(event.slice("model:".length));
398 }707 }
399708
400 function handleEnvironmentMenuAction(event: string) {709 function handleEnvironmentMenuAction(event: string) {
401 if (!event.startsWith("environment:")) {710 if (isIncomingShareTransferPending || !event.startsWith("environment:")) {
402 return;711 return;
403 }712 }
404 flow.selectEnvironment(EnvironmentId.make(event.slice("environment:".length)));713 flow.selectEnvironment(EnvironmentId.make(event.slice("environment:".length)));
405 }714 }
406715
407 function handleOptionsMenuAction(event: string) {716 function handleOptionsMenuAction(event: string) {
717 if (isIncomingShareTransferPending) {
718 return;
719 }
408 const providerOptions = applyProviderOptionMenuEvent(providerOptionDescriptors, event);720 const providerOptions = applyProviderOptionMenuEvent(providerOptionDescriptors, event);
409 if (providerOptions) {721 if (providerOptions) {
410 flow.setSelectedModelOptions(providerOptions);722 flow.setSelectedModelOptions(providerOptions);
...@@ -424,6 +736,9 @@ export function NewTaskDraftScreen(props: {...@@ -424,6 +736,9 @@ export function NewTaskDraftScreen(props: {
424 }736 }
425737
426 function handleWorkspaceMenuAction(event: string) {738 function handleWorkspaceMenuAction(event: string) {
739 if (isIncomingShareTransferPending) {
740 return;
741 }
427 if (event.startsWith("workspace:mode:")) {742 if (event.startsWith("workspace:mode:")) {
428 flow.setWorkspaceMode(743 flow.setWorkspaceMode(
429 event.slice("workspace:mode:".length) as Parameters<typeof flow.setWorkspaceMode>[0],744 event.slice("workspace:mode:".length) as Parameters<typeof flow.setWorkspaceMode>[0],
...@@ -444,6 +759,9 @@ export function NewTaskDraftScreen(props: {...@@ -444,6 +759,9 @@ export function NewTaskDraftScreen(props: {
444 }759 }
445760
446 async function handlePickImages(): Promise<void> {761 async function handlePickImages(): Promise<void> {
762 if (isIncomingShareTransferPending) {
763 return;
764 }
447 const result = await pickComposerImages({ existingCount: flow.attachments.length });765 const result = await pickComposerImages({ existingCount: flow.attachments.length });
448 if (result.images.length > 0) {766 if (result.images.length > 0) {
449 flow.appendAttachments(result.images);767 flow.appendAttachments(result.images);
...@@ -526,8 +844,7 @@ export function NewTaskDraftScreen(props: {...@@ -526,8 +844,7 @@ export function NewTaskDraftScreen(props: {
526 if (editingPendingTask) {844 if (editingPendingTask) {
527 flow.finishEditingPendingTask();845 flow.finishEditingPendingTask();
528 } else {846 } else {
529 flow.setPrompt("");847 clearComposerDraftContent(draftKey);
530 flow.clearAttachments();
531 }848 }
532 navigation.getParent()?.goBack();849 navigation.getParent()?.goBack();
533 return;850 return;
...@@ -585,8 +902,7 @@ export function NewTaskDraftScreen(props: {...@@ -585,8 +902,7 @@ export function NewTaskDraftScreen(props: {
585 }902 }
586 flow.finishEditingPendingTask();903 flow.finishEditingPendingTask();
587 } else {904 } else {
588 flow.setPrompt("");905 clearComposerDraftContent(draftKey);
589 flow.clearAttachments();
590 }906 }
591 navigation.dispatch(907 navigation.dispatch(
592 StackActions.replace("Thread", {908 StackActions.replace("Thread", {
...@@ -620,12 +936,15 @@ export function NewTaskDraftScreen(props: {...@@ -620,12 +936,15 @@ export function NewTaskDraftScreen(props: {
620 Boolean(flow.selectedProject) &&936 Boolean(flow.selectedProject) &&
621 Boolean(flow.selectedModel) &&937 Boolean(flow.selectedModel) &&
622 flow.prompt.trim().length > 0 &&938 flow.prompt.trim().length > 0 &&
939 isIncomingShareReady &&
940 !isImportingShare &&
623 !flow.submitting &&941 !flow.submitting &&
624 !(flow.workspaceMode === "worktree" && !flow.selectedBranchName);942 !(flow.workspaceMode === "worktree" && !flow.selectedBranchName);
625 const promptEditor = (943 const promptEditor = (
626 <ComposerEditor944 <ComposerEditor
627 ref={promptInputRef}945 ref={promptInputRef}
628 autoFocus={!isAndroid}946 autoFocus={!isAndroid}
947 editable={!isIncomingShareTransferPending}
629 multiline948 multiline
630 scrollEnabled={isExpanded}949 scrollEnabled={isExpanded}
631 value={flow.prompt}950 value={flow.prompt}
...@@ -660,6 +979,7 @@ export function NewTaskDraftScreen(props: {...@@ -660,6 +979,7 @@ export function NewTaskDraftScreen(props: {
660 icon="plus"979 icon="plus"
661 onPress={() => void handlePickImages()}980 onPress={() => void handlePickImages()}
662 showChevron={false}981 showChevron={false}
982 disabled={isIncomingShareTransferPending}
663 />983 />
664 <ControlPillMenu984 <ControlPillMenu
665 actions={modelMenuActions}985 actions={modelMenuActions}
...@@ -667,6 +987,7 @@ export function NewTaskDraftScreen(props: {...@@ -667,6 +987,7 @@ export function NewTaskDraftScreen(props: {
667 >987 >
668 <ComposerToolbarTrigger988 <ComposerToolbarTrigger
669 accessibilityLabel="Model"989 accessibilityLabel="Model"
990 disabled={isIncomingShareTransferPending}
670 iconNode={<ProviderIcon provider={flow.selectedModelOption?.providerDriver} size={16} />}991 iconNode={<ProviderIcon provider={flow.selectedModelOption?.providerDriver} size={16} />}
671 label={flow.selectedModelOption?.label ?? "Model"}992 label={flow.selectedModelOption?.label ?? "Model"}
672 />993 />
...@@ -677,6 +998,7 @@ export function NewTaskDraftScreen(props: {...@@ -677,6 +998,7 @@ export function NewTaskDraftScreen(props: {
677 >998 >
678 <ComposerToolbarTrigger999 <ComposerToolbarTrigger
679 accessibilityLabel="Configuration"1000 accessibilityLabel="Configuration"
1001 disabled={isIncomingShareTransferPending}
680 icon="slider.horizontal.3"1002 icon="slider.horizontal.3"
681 label={configurationLabel}1003 label={configurationLabel}
682 />1004 />
...@@ -687,6 +1009,7 @@ export function NewTaskDraftScreen(props: {...@@ -687,6 +1009,7 @@ export function NewTaskDraftScreen(props: {
687 >1009 >
688 <ComposerToolbarTrigger1010 <ComposerToolbarTrigger
689 accessibilityLabel="Environment"1011 accessibilityLabel="Environment"
1012 disabled={isIncomingShareTransferPending}
690 icon="desktopcomputer"1013 icon="desktopcomputer"
691 label={selectedEnvironmentLabel}1014 label={selectedEnvironmentLabel}
692 />1015 />
...@@ -697,6 +1020,7 @@ export function NewTaskDraftScreen(props: {...@@ -697,6 +1020,7 @@ export function NewTaskDraftScreen(props: {
697 >1020 >
698 <ComposerToolbarTrigger1021 <ComposerToolbarTrigger
699 accessibilityLabel="Workspace"1022 accessibilityLabel="Workspace"
1023 disabled={isIncomingShareTransferPending}
700 icon="point.topleft.down.curvedto.point.bottomright.up"1024 icon="point.topleft.down.curvedto.point.bottomright.up"
701 label={workspaceLabel}1025 label={workspaceLabel}
702 />1026 />
...@@ -763,7 +1087,9 @@ export function NewTaskDraftScreen(props: {...@@ -763,7 +1087,9 @@ export function NewTaskDraftScreen(props: {
763 <View className="pb-2.5">1087 <View className="pb-2.5">
764 <ComposerAttachmentStrip1088 <ComposerAttachmentStrip
765 attachments={flow.attachments}1089 attachments={flow.attachments}
766 onRemove={flow.removeAttachment}1090 onRemove={
1091 isIncomingShareTransferPending ? () => undefined : flow.removeAttachment
1092 }
767 />1093 />
768 </View>1094 </View>
769 ) : null}1095 ) : null}
...@@ -807,7 +1133,7 @@ export function NewTaskDraftScreen(props: {...@@ -807,7 +1133,7 @@ export function NewTaskDraftScreen(props: {
807 <View className="px-4 pt-3">1133 <View className="px-4 pt-3">
808 <ComposerAttachmentStrip1134 <ComposerAttachmentStrip
809 attachments={flow.attachments}1135 attachments={flow.attachments}
810 onRemove={flow.removeAttachment}1136 onRemove={isIncomingShareTransferPending ? () => undefined : flow.removeAttachment}
811 imageSize={88}1137 imageSize={88}
812 imageBorderRadius={20}1138 imageBorderRadius={20}
813 />1139 />
apps/mobile/src/features/threads/NewTaskRouteScreen.tsx+112-27
...@@ -1,9 +1,9 @@...@@ -1,9 +1,9 @@
1import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";1import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
2import { useNavigation } from "@react-navigation/native";2import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native";
3import { SymbolView } from "../../components/AppSymbol";3import { SymbolView } from "../../components/AppSymbol";
4import type { EnvironmentId, ProjectId } from "@t3tools/contracts";4import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
5import { useMemo } from "react";5import { useEffect, useMemo, useRef } from "react";
6import { ActivityIndicator, Platform, Pressable, ScrollView, View } from "react-native";6import { ActivityIndicator, Alert, Platform, Pressable, ScrollView, View } from "react-native";
7import { useSafeAreaInsets } from "react-native-safe-area-context";7import { useSafeAreaInsets } from "react-native-safe-area-context";
8import { useThemeColor } from "../../lib/useThemeColor";8import { useThemeColor } from "../../lib/useThemeColor";
9import { cn } from "../../lib/cn";9import { cn } from "../../lib/cn";
...@@ -16,6 +16,11 @@ import type { WorkspaceState } from "../../state/workspaceModel";...@@ -16,6 +16,11 @@ import type { WorkspaceState } from "../../state/workspaceModel";
16import { useWorkspaceState } from "../../state/workspace";16import { useWorkspaceState } from "../../state/workspace";
17import { groupProjectsByRepository } from "../../lib/repositoryGroups";17import { groupProjectsByRepository } from "../../lib/repositoryGroups";
18import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";18import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
19import { useIncomingShare } from "../sharing/IncomingShareProvider";
20
21type NewTaskRouteParams = {
22 readonly incomingShareId?: string | string[];
23};
1924
20function deriveProjectEmptyState(catalogState: WorkspaceState): {25function deriveProjectEmptyState(catalogState: WorkspaceState): {
21 readonly title: string;26 readonly title: string;
...@@ -72,15 +77,29 @@ function deriveProjectEmptyState(catalogState: WorkspaceState): {...@@ -72,15 +77,29 @@ function deriveProjectEmptyState(catalogState: WorkspaceState): {
72 };77 };
73}78}
7479
75export function NewTaskRouteScreen() {80export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRouteParams | undefined>) {
76 const projects = useProjects();81 const projects = useProjects();
77 const threads = useThreadShells();82 const threads = useThreadShells();
78 const { state: catalogState } = useWorkspaceState();83 const { state: catalogState } = useWorkspaceState();
79 const navigation = useNavigation();84 const navigation = useNavigation();
85 const isFocused = useIsFocused();
80 const { layout } = useAdaptiveWorkspaceLayout();86 const { layout } = useAdaptiveWorkspaceLayout();
81 const insets = useSafeAreaInsets();87 const insets = useSafeAreaInsets();
82 const chevronColor = useThemeColor("--color-chevron");88 const chevronColor = useThemeColor("--color-chevron");
83 const accentColor = useThemeColor("--color-icon-muted");89 const accentColor = useThemeColor("--color-icon-muted");
90 const { getShare, releaseShareReservation } = useIncomingShare();
91 const routeShareId = Array.isArray(route.params?.incomingShareId)
92 ? route.params.incomingShareId[0]
93 : route.params?.incomingShareId;
94 const incomingShare = routeShareId ? getShare(routeShareId) : null;
95 const incomingShareSubtitle = incomingShare
96 ? incomingShare.attachments.length === 0
97 ? "Choose a project for what you shared"
98 : incomingShare.attachments.length === 1
99 ? "Choose a project for the image you shared"
100 : `Choose a project for the ${incomingShare.attachments.length} images you shared`
101 : null;
102 const screenTitle = incomingShare ? "Start a task" : "Choose project";
84 const repositoryGroups = useMemo(103 const repositoryGroups = useMemo(
85 () => groupProjectsByRepository({ projects, threads }),104 () => groupProjectsByRepository({ projects, threads }),
86 [projects, threads],105 [projects, threads],
...@@ -109,6 +128,70 @@ export function NewTaskRouteScreen() {...@@ -109,6 +128,70 @@ export function NewTaskRouteScreen() {
109 return nextItems;128 return nextItems;
110 }, [repositoryGroups]);129 }, [repositoryGroups]);
111 const projectEmptyState = deriveProjectEmptyState(catalogState);130 const projectEmptyState = deriveProjectEmptyState(catalogState);
131 const resumedDestinationKeyRef = useRef<string | null>(null);
132 const reservedDestinationProject = incomingShare?.destination
133 ? (projects.find(
134 (project) =>
135 project.environmentId === incomingShare.destination?.environmentId &&
136 project.id === incomingShare.destination?.projectId,
137 ) ?? null)
138 : null;
139
140 async function selectProject(item: (typeof items)[number]): Promise<void> {
141 if (incomingShare?.destination && !reservedDestinationProject) {
142 try {
143 await releaseShareReservation(incomingShare.id, incomingShare.destination);
144 } catch (error) {
145 Alert.alert(
146 "Could not change project",
147 error instanceof Error
148 ? error.message
149 : "The shared content reservation could not be updated.",
150 );
151 return;
152 }
153 }
154 navigation.navigate("NewTaskSheet", {
155 screen: "NewTaskDraft",
156 params: {
157 environmentId: item.environmentId,
158 projectId: item.id,
159 title: item.title,
160 incomingShareId: incomingShare?.id,
161 },
162 });
163 }
164
165 useEffect(() => {
166 const destination = incomingShare?.destination;
167 if (!destination) {
168 resumedDestinationKeyRef.current = null;
169 return;
170 }
171 if (!isFocused) {
172 // Returning from the reserved draft is a fresh resume attempt. Keeping
173 // this latch set would leave every project row disabled with no route.
174 resumedDestinationKeyRef.current = null;
175 return;
176 }
177 const destinationKey = `${incomingShare.id}:${destination.environmentId}:${destination.projectId}`;
178 if (resumedDestinationKeyRef.current === destinationKey) {
179 return;
180 }
181 if (!reservedDestinationProject) {
182 return;
183 }
184 resumedDestinationKeyRef.current = destinationKey;
185 navigation.navigate("NewTaskSheet", {
186 screen: "NewTaskDraft",
187 params: {
188 environmentId: reservedDestinationProject.environmentId,
189 projectId: reservedDestinationProject.id,
190 title: reservedDestinationProject.title,
191 incomingShareId: incomingShare.id,
192 },
193 });
194 }, [incomingShare, isFocused, navigation, reservedDestinationProject]);
112195
113 return (196 return (
114 <View collapsable={false} className="flex-1 bg-sheet">197 <View collapsable={false} className="flex-1 bg-sheet">
...@@ -117,7 +200,8 @@ export function NewTaskRouteScreen() {...@@ -117,7 +200,8 @@ export function NewTaskRouteScreen() {
117 {/* Android renders its own in-screen header instead of the native bar. */}200 {/* Android renders its own in-screen header instead of the native bar. */}
118 <NativeStackScreenOptions options={{ headerShown: false }} />201 <NativeStackScreenOptions options={{ headerShown: false }} />
119 <AndroidScreenHeader202 <AndroidScreenHeader
120 title="Choose project"203 title={screenTitle}
204 subtitle={incomingShareSubtitle}
121 onBack={layout.usesSplitView ? () => navigation.goBack() : undefined}205 onBack={layout.usesSplitView ? () => navigation.goBack() : undefined}
122 actions={[206 actions={[
123 {207 {
...@@ -129,21 +213,29 @@ export function NewTaskRouteScreen() {...@@ -129,21 +213,29 @@ export function NewTaskRouteScreen() {
129 />213 />
130 </>214 </>
131 ) : (215 ) : (
132 <NativeHeaderToolbar placement="right">216 <>
133 {layout.usesSplitView ? (217 <NativeStackScreenOptions
218 options={{
219 title: screenTitle,
220 unstable_headerSubtitle: incomingShareSubtitle ?? undefined,
221 }}
222 />
223 <NativeHeaderToolbar placement="right">
224 {layout.usesSplitView ? (
225 <NativeHeaderToolbar.Button
226 accessibilityLabel="Close new task"
227 icon="xmark"
228 onPress={() => navigation.goBack()}
229 separateBackground
230 />
231 ) : null}
134 <NativeHeaderToolbar.Button232 <NativeHeaderToolbar.Button
135 accessibilityLabel="Close new task"233 icon="plus"
136 icon="xmark"234 onPress={() => navigation.navigate("NewTaskSheet", { screen: "AddProject" })}
137 onPress={() => navigation.goBack()}
138 separateBackground235 separateBackground
139 />236 />
140 ) : null}237 </NativeHeaderToolbar>
141 <NativeHeaderToolbar.Button238 </>
142 icon="plus"
143 onPress={() => navigation.navigate("NewTaskSheet", { screen: "AddProject" })}
144 separateBackground
145 />
146 </NativeHeaderToolbar>
147 )}239 )}
148240
149 <ScrollView241 <ScrollView
...@@ -152,6 +244,7 @@ export function NewTaskRouteScreen() {...@@ -152,6 +244,7 @@ export function NewTaskRouteScreen() {
152 className="flex-1"244 className="flex-1"
153 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}245 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
154 contentContainerStyle={{246 contentContainerStyle={{
247 gap: 12,
155 paddingHorizontal: 20,248 paddingHorizontal: 20,
156 paddingTop: 8,249 paddingTop: 8,
157 }}250 }}
...@@ -194,16 +287,8 @@ export function NewTaskRouteScreen() {...@@ -194,16 +287,8 @@ export function NewTaskRouteScreen() {
194 return (287 return (
195 <Pressable288 <Pressable
196 key={item.key}289 key={item.key}
197 onPress={() =>290 disabled={reservedDestinationProject !== null}
198 navigation.navigate("NewTaskSheet", {291 onPress={() => void selectProject(item)}
199 screen: "NewTaskDraft",
200 params: {
201 environmentId: item.environmentId,
202 projectId: item.id,
203 title: item.title,
204 },
205 })
206 }
207 className={cn(292 className={cn(
208 "bg-card px-4 py-3.5",293 "bg-card px-4 py-3.5",
209 !isFirst && "border-t border-border-subtle",294 !isFirst && "border-t border-border-subtle",
apps/mobile/src/lib/base64.ts created+4
...@@ -0,0 +1,4 @@
1export function estimateBase64ByteSize(base64: string): number {
2 const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0;
3 return Math.floor((base64.length * 3) / 4) - padding;
4}
apps/mobile/src/lib/composerImages.ts+1-5
...@@ -3,6 +3,7 @@ import {...@@ -3,6 +3,7 @@ import {
3 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,3 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
4 type UploadChatImageAttachment,4 type UploadChatImageAttachment,
5} from "@t3tools/contracts";5} from "@t3tools/contracts";
6import { estimateBase64ByteSize } from "./base64";
6import { uuidv4 } from "./uuid";7import { uuidv4 } from "./uuid";
78
8export interface DraftComposerImageAttachment extends UploadChatImageAttachment {9export interface DraftComposerImageAttachment extends UploadChatImageAttachment {
...@@ -25,11 +26,6 @@ export function toUploadChatImageAttachments(...@@ -25,11 +26,6 @@ export function toUploadChatImageAttachments(
2526
26const OWNED_PASTED_IMAGE_DIRECTORY = "t3-composer-paste";27const OWNED_PASTED_IMAGE_DIRECTORY = "t3-composer-paste";
2728
28function estimateBase64ByteSize(base64: string): number {
29 const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0;
30 return Math.floor((base64.length * 3) / 4) - padding;
31}
32
33async function loadImagePicker() {29async function loadImagePicker() {
34 try {30 try {
35 return await import("expo-image-picker");31 return await import("expo-image-picker");
apps/mobile/src/lib/serialized-async-queue.test.ts created+45
...@@ -0,0 +1,45 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { SerializedAsyncQueue } from "./serialized-async-queue";
4
5function deferred() {
6 let resolve!: () => void;
7 const promise = new Promise<void>((next) => {
8 resolve = next;
9 });
10 return { promise, resolve };
11}
12
13describe("SerializedAsyncQueue", () => {
14 it("does not let a newer operation overtake an in-flight operation", async () => {
15 const queue = new SerializedAsyncQueue();
16 const firstGate = deferred();
17 const events: string[] = [];
18
19 const first = queue.run(async () => {
20 events.push("first:start");
21 await firstGate.promise;
22 events.push("first:end");
23 });
24 const second = queue.run(async () => {
25 events.push("second");
26 });
27
28 await Promise.resolve();
29 expect(events).toEqual(["first:start"]);
30 firstGate.resolve();
31 await Promise.all([first, second]);
32 expect(events).toEqual(["first:start", "first:end", "second"]);
33 });
34
35 it("continues after a rejected operation", async () => {
36 const queue = new SerializedAsyncQueue();
37 const first = queue.run(async () => {
38 throw new Error("failed");
39 });
40 const second = queue.run(async () => "recovered");
41
42 await expect(first).rejects.toThrow("failed");
43 await expect(second).resolves.toBe("recovered");
44 });
45});
apps/mobile/src/lib/serialized-async-queue.ts created+16
...@@ -0,0 +1,16 @@
1/**
2 * Runs asynchronous operations in call order while keeping the queue usable
3 * after an individual operation rejects.
4 */
5export class SerializedAsyncQueue {
6 private tail: Promise<void> = Promise.resolve();
7
8 run<T>(operation: () => Promise<T>): Promise<T> {
9 const result = this.tail.then(operation, operation);
10 this.tail = result.then(
11 () => undefined,
12 () => undefined,
13 );
14 return result;
15 }
16}
apps/mobile/src/native/StackHeader.tsx+2-1
...@@ -195,7 +195,7 @@ function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null {...@@ -195,7 +195,7 @@ function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null {
195 if (typeName === "NativeHeaderToolbarButton") {195 if (typeName === "NativeHeaderToolbarButton") {
196 return {196 return {
197 type: "button",197 type: "button",
198 label: "",198 label: typeof child.props.label === "string" ? child.props.label : "",
199 accessibilityLabel:199 accessibilityLabel:
200 typeof child.props.accessibilityLabel === "string"200 typeof child.props.accessibilityLabel === "string"
201 ? child.props.accessibilityLabel201 ? child.props.accessibilityLabel
...@@ -293,6 +293,7 @@ function NativeHeaderToolbarButton(_props: {...@@ -293,6 +293,7 @@ function NativeHeaderToolbarButton(_props: {
293 readonly accessibilityLabel?: string;293 readonly accessibilityLabel?: string;
294 readonly disabled?: boolean;294 readonly disabled?: boolean;
295 readonly icon?: string;295 readonly icon?: string;
296 readonly label?: string;
296 readonly onPress?: () => void;297 readonly onPress?: () => void;
297 readonly separateBackground?: boolean;298 readonly separateBackground?: boolean;
298 readonly tintColor?: ColorValue;299 readonly tintColor?: ColorValue;
apps/mobile/src/state/use-composer-drafts.test.ts+89-1
...@@ -8,7 +8,9 @@ import {...@@ -8,7 +8,9 @@ import {
8 decodePersistedComposerDrafts,8 decodePersistedComposerDrafts,
9 type ComposerDraft,9 type ComposerDraft,
10 getComposerDraftSnapshot,10 getComposerDraftSnapshot,
11 mergeComposerDraftContentState,
11 removeComposerDraftsForEnvironment,12 removeComposerDraftsForEnvironment,
13 restoreComposerDraftSnapshotState,
12} from "./use-composer-drafts";14} from "./use-composer-drafts";
1315
14const DRAFT: ComposerDraft = {16const DRAFT: ComposerDraft = {
...@@ -94,6 +96,7 @@ describe("mobile composer drafts", () => {...@@ -94,6 +96,7 @@ describe("mobile composer drafts", () => {
94 const draft: ComposerDraft = {96 const draft: ComposerDraft = {
95 text: "send this",97 text: "send this",
96 attachments: [],98 attachments: [],
99 importedShareIds: ["share-1"],
97 modelSelection: {100 modelSelection: {
98 instanceId: ProviderInstanceId.make("codex"),101 instanceId: ProviderInstanceId.make("codex"),
99 model: "gpt-5.4",102 model: "gpt-5.4",
...@@ -108,7 +111,8 @@ describe("mobile composer drafts", () => {...@@ -108,7 +111,8 @@ describe("mobile composer drafts", () => {
108111
109 expect(clearComposerDraftContentState({ [draftKey]: draft }, draftKey)).toEqual({112 expect(clearComposerDraftContentState({ [draftKey]: draft }, draftKey)).toEqual({
110 [draftKey]: {113 [draftKey]: {
111 ...draft,114 modelSelection: draft.modelSelection,
115 workspaceSelection: draft.workspaceSelection,
112 text: "",116 text: "",
113 attachments: [],117 attachments: [],
114 },118 },
...@@ -131,6 +135,90 @@ describe("mobile composer drafts", () => {...@@ -131,6 +135,90 @@ describe("mobile composer drafts", () => {
131 expect(getComposerDraftSnapshot(draftKey)).toEqual(selectedDraft);135 expect(getComposerDraftSnapshot(draftKey)).toEqual(selectedDraft);
132 });136 });
133137
138 it("merges shared content into a project draft without duplicating retries", () => {
139 const draftKey = "new-task:environment-1:project-1";
140 const sharedAttachment = {
141 id: "share-1:image:0",
142 type: "image" as const,
143 name: "Screenshot.png",
144 mimeType: "image/png",
145 sizeBytes: 3,
146 dataUrl: "data:image/png;base64,YWJj",
147 previewUri: "data:image/png;base64,YWJj",
148 };
149 const existing: Record<string, ComposerDraft> = {
150 [draftKey]: { text: "Existing context", attachments: [] },
151 };
152 const content = {
153 text: "Shared note",
154 attachments: [sharedAttachment],
155 sourceShareId: "share-1",
156 };
157
158 const merged = mergeComposerDraftContentState(existing, draftKey, content);
159 expect(merged[draftKey]).toMatchObject({
160 text: "Existing context\n\nShared note",
161 attachments: [sharedAttachment],
162 importedShareIds: ["share-1"],
163 });
164 expect(mergeComposerDraftContentState(merged, draftKey, content)).toBe(merged);
165
166 const edited = {
167 ...merged,
168 [draftKey]: { ...merged[draftKey]!, text: "User edited the imported context" },
169 };
170 expect(mergeComposerDraftContentState(edited, draftKey, content)).toBe(edited);
171 });
172
173 it("preserves existing images when shared content exceeds the draft attachment limit", () => {
174 const draftKey = "new-task:environment-1:project-1";
175 const image = (id: string) => ({
176 id,
177 type: "image" as const,
178 name: `${id}.png`,
179 mimeType: "image/png",
180 sizeBytes: 3,
181 dataUrl: "data:image/png;base64,YWJj",
182 previewUri: "data:image/png;base64,YWJj",
183 });
184 const existingImage = image("existing");
185 const sharedImages = Array.from({ length: 8 }, (_, index) => image(`shared-${index}`));
186
187 const merged = mergeComposerDraftContentState(
188 { [draftKey]: { text: "", attachments: [existingImage] } },
189 draftKey,
190 { text: "", attachments: sharedImages },
191 );
192
193 expect(merged[draftKey]?.attachments).toHaveLength(8);
194 expect(merged[draftKey]?.attachments[0]).toEqual(existingImage);
195 expect(merged[draftKey]?.attachments.at(-1)?.id).toBe("shared-6");
196 });
197
198 it("restores the exact draft captured before an interrupted share import", () => {
199 const draftKey = "new-task:environment-1:project-1";
200 const beforeImport: ComposerDraft = {
201 text: "Existing context",
202 attachments: [],
203 runtimeMode: "approval-required",
204 };
205 const imported: ComposerDraft = {
206 ...beforeImport,
207 text: "Existing context\n\nShared note",
208 importedShareIds: ["share-1"],
209 };
210
211 expect(
212 restoreComposerDraftSnapshotState({ [draftKey]: imported }, draftKey, beforeImport),
213 ).toEqual({ [draftKey]: beforeImport });
214 expect(
215 restoreComposerDraftSnapshotState({ [draftKey]: imported }, draftKey, {
216 text: "",
217 attachments: [],
218 }),
219 ).toEqual({});
220 });
221
134 it("removes only drafts owned by the selected environment", () => {222 it("removes only drafts owned by the selected environment", () => {
135 const environmentId = EnvironmentId.make("environment-cloud");223 const environmentId = EnvironmentId.make("environment-cloud");
136 const retainedEnvironmentId = EnvironmentId.make("environment-local");224 const retainedEnvironmentId = EnvironmentId.make("environment-local");
apps/mobile/src/state/use-composer-drafts.ts+146-2
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import {2import {
3 ModelSelection as ModelSelectionSchema,3 ModelSelection as ModelSelectionSchema,
4 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
4 ProviderInteractionMode as ProviderInteractionModeSchema,5 ProviderInteractionMode as ProviderInteractionModeSchema,
5 RuntimeMode as RuntimeModeSchema,6 RuntimeMode as RuntimeModeSchema,
6 type EnvironmentId,7 type EnvironmentId,
...@@ -14,6 +15,7 @@ import { Atom } from "effect/unstable/reactivity";...@@ -14,6 +15,7 @@ import { Atom } from "effect/unstable/reactivity";
1415
15import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema";16import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema";
16import type { DraftComposerImageAttachment } from "../lib/composerImages";17import type { DraftComposerImageAttachment } from "../lib/composerImages";
18import { SerializedAsyncQueue } from "../lib/serialized-async-queue";
17import { appAtomRegistry } from "./atom-registry";19import { appAtomRegistry } from "./atom-registry";
1820
19const COMPOSER_DRAFTS_SCHEMA_VERSION = 1;21const COMPOSER_DRAFTS_SCHEMA_VERSION = 1;
...@@ -38,12 +40,19 @@ export class ComposerDraftPersistenceError extends Schema.TaggedErrorClass<Compo...@@ -38,12 +40,19 @@ export class ComposerDraftPersistenceError extends Schema.TaggedErrorClass<Compo
38export interface ComposerDraft {40export interface ComposerDraft {
39 readonly text: string;41 readonly text: string;
40 readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;42 readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;
43 readonly importedShareIds?: ReadonlyArray<string>;
41 readonly modelSelection?: ModelSelection;44 readonly modelSelection?: ModelSelection;
42 readonly runtimeMode?: RuntimeMode;45 readonly runtimeMode?: RuntimeMode;
43 readonly interactionMode?: ProviderInteractionMode;46 readonly interactionMode?: ProviderInteractionMode;
44 readonly workspaceSelection?: ComposerDraftWorkspaceSelection;47 readonly workspaceSelection?: ComposerDraftWorkspaceSelection;
45}48}
4649
50export interface ComposerDraftContent {
51 readonly text: string;
52 readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;
53 readonly sourceShareId?: string;
54}
55
47export interface ComposerDraftWorkspaceSelection {56export interface ComposerDraftWorkspaceSelection {
48 readonly mode: "local" | "worktree";57 readonly mode: "local" | "worktree";
49 readonly branch: string | null;58 readonly branch: string | null;
...@@ -66,6 +75,7 @@ const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({...@@ -66,6 +75,7 @@ const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({
66const ComposerDraftSchema = Schema.Struct({75const ComposerDraftSchema = Schema.Struct({
67 text: Schema.String,76 text: Schema.String,
68 attachments: Schema.Array(DraftComposerImageAttachmentSchema),77 attachments: Schema.Array(DraftComposerImageAttachmentSchema),
78 importedShareIds: Schema.optional(Schema.Array(Schema.String)),
69 modelSelection: Schema.optional(ModelSelectionSchema),79 modelSelection: Schema.optional(ModelSelectionSchema),
70 runtimeMode: Schema.optional(RuntimeModeSchema),80 runtimeMode: Schema.optional(RuntimeModeSchema),
71 interactionMode: Schema.optional(ProviderInteractionModeSchema),81 interactionMode: Schema.optional(ProviderInteractionModeSchema),
...@@ -93,6 +103,7 @@ export const composerDraftsAtom = Atom.make<Record<string, ComposerDraft>>({}).p...@@ -93,6 +103,7 @@ export const composerDraftsAtom = Atom.make<Record<string, ComposerDraft>>({}).p
93103
94let loadPromise: Promise<void> | null = null;104let loadPromise: Promise<void> | null = null;
95let persistTimer: ReturnType<typeof setTimeout> | null = null;105let persistTimer: ReturnType<typeof setTimeout> | null = null;
106const persistenceQueue = new SerializedAsyncQueue();
96107
97function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft {108function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft {
98 if (!draft) {109 if (!draft) {
...@@ -193,7 +204,7 @@ async function writePersistedComposerDrafts(drafts: Record<string, ComposerDraft...@@ -193,7 +204,7 @@ async function writePersistedComposerDrafts(drafts: Record<string, ComposerDraft
193204
194async function savePersistedComposerDrafts(drafts: Record<string, ComposerDraft>): Promise<void> {205async function savePersistedComposerDrafts(drafts: Record<string, ComposerDraft>): Promise<void> {
195 try {206 try {
196 await writePersistedComposerDrafts(drafts);207 await persistenceQueue.run(() => writePersistedComposerDrafts(drafts));
197 } catch (error) {208 } catch (error) {
198 console.warn("[composer-drafts] failed to persist drafts", error);209 console.warn("[composer-drafts] failed to persist drafts", error);
199 // Draft persistence is best-effort; in-memory drafts still keep working.210 // Draft persistence is best-effort; in-memory drafts still keep working.
...@@ -366,8 +377,9 @@ export function clearComposerDraftContentState(...@@ -366,8 +377,9 @@ export function clearComposerDraftContentState(
366 if (!existing) {377 if (!existing) {
367 return current;378 return current;
368 }379 }
380 const { importedShareIds: _importedShareIds, ...retained } = existing;
369 const draft = {381 const draft = {
370 ...existing,382 ...retained,
371 text: "",383 text: "",
372 attachments: [],384 attachments: [],
373 };385 };
...@@ -382,6 +394,138 @@ export function clearComposerDraftContentState(...@@ -382,6 +394,138 @@ export function clearComposerDraftContentState(
382 };394 };
383}395}
384396
397export function restoreComposerDraftSnapshotState(
398 current: Record<string, ComposerDraft>,
399 draftKey: string,
400 snapshot: ComposerDraft,
401): Record<string, ComposerDraft> {
402 const next = { ...current };
403 if (isEmptyDraft(snapshot)) {
404 delete next[draftKey];
405 } else {
406 next[draftKey] = snapshot;
407 }
408 return next;
409}
410
411function mergeComposerDraftText(existing: string, incoming: string): string {
412 if (incoming.length === 0) {
413 return existing;
414 }
415 if (existing.length === 0) {
416 return incoming;
417 }
418 // Import retries are possible after an interrupted native handoff. Keep the
419 // operation idempotent when the same shared text is already present.
420 if (existing === incoming || existing.endsWith(`\n\n${incoming}`)) {
421 return existing;
422 }
423 return `${existing}\n\n${incoming}`;
424}
425
426export function mergeComposerDraftContentState(
427 current: Record<string, ComposerDraft>,
428 draftKey: string,
429 content: ComposerDraftContent,
430): Record<string, ComposerDraft> {
431 const existing = normalizeDraft(current[draftKey]);
432 if (content.sourceShareId && existing.importedShareIds?.includes(content.sourceShareId)) {
433 return current;
434 }
435 const attachmentIds = new Set(existing.attachments.map((attachment) => attachment.id));
436 const incomingAttachments = content.attachments.filter((attachment) => {
437 if (attachmentIds.has(attachment.id)) {
438 return false;
439 }
440 attachmentIds.add(attachment.id);
441 return true;
442 });
443 const attachments = [...existing.attachments, ...incomingAttachments].slice(
444 0,
445 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
446 );
447 const text = mergeComposerDraftText(existing.text, content.text);
448 const importedShareIds = content.sourceShareId
449 ? [...(existing.importedShareIds ?? []), content.sourceShareId]
450 : existing.importedShareIds;
451 if (
452 text === existing.text &&
453 attachments.length === existing.attachments.length &&
454 importedShareIds === existing.importedShareIds
455 ) {
456 return current;
457 }
458 return {
459 ...current,
460 [draftKey]: {
461 ...existing,
462 text,
463 attachments,
464 ...(importedShareIds ? { importedShareIds } : {}),
465 },
466 };
467}
468
469/**
470 * Atomically moves an incoming share into a project-scoped composer draft.
471 * The durable write happens before the share inbox item can be acknowledged.
472 */
473export async function mergeComposerDraftContent(
474 draftKey: string,
475 content: ComposerDraftContent,
476): Promise<{ readonly skippedAttachmentCount: number }> {
477 ensureComposerDraftsLoaded();
478 if (loadPromise !== null) {
479 await loadPromise;
480 }
481 if (persistTimer !== null) {
482 clearTimeout(persistTimer);
483 persistTimer = null;
484 }
485 const current = appAtomRegistry.get(composerDraftsAtom);
486 const next = mergeComposerDraftContentState(current, draftKey, content);
487 const currentAttachmentIds = new Set(
488 normalizeDraft(current[draftKey]).attachments.map((attachment) => attachment.id),
489 );
490 const nextAttachmentIds = new Set(
491 normalizeDraft(next[draftKey]).attachments.map((attachment) => attachment.id),
492 );
493 const skippedAttachmentCount = content.attachments.filter(
494 (attachment) =>
495 !currentAttachmentIds.has(attachment.id) && !nextAttachmentIds.has(attachment.id),
496 ).length;
497 // Publish the content and its import receipt together before the filesystem
498 // await. Typing during persistence then builds on the receipt-bearing state,
499 // and its debounced write is serialized after this transaction.
500 if (next !== current) {
501 appAtomRegistry.set(composerDraftsAtom, next);
502 }
503 await persistenceQueue.run(() => writePersistedComposerDrafts(next));
504 return { skippedAttachmentCount };
505}
506
507/** Restores the exact content/settings captured before an interrupted import. */
508export async function restoreComposerDraftSnapshot(
509 draftKey: string,
510 snapshot: ComposerDraft,
511): Promise<void> {
512 ensureComposerDraftsLoaded();
513 if (loadPromise !== null) {
514 await loadPromise;
515 }
516 if (persistTimer !== null) {
517 clearTimeout(persistTimer);
518 persistTimer = null;
519 }
520 const next = restoreComposerDraftSnapshotState(
521 appAtomRegistry.get(composerDraftsAtom),
522 draftKey,
523 snapshot,
524 );
525 appAtomRegistry.set(composerDraftsAtom, next);
526 await persistenceQueue.run(() => writePersistedComposerDrafts(next));
527}
528
385export function clearComposerDraftContent(draftKey: string): void {529export function clearComposerDraftContent(draftKey: string): void {
386 updateComposerDrafts((current) => clearComposerDraftContentState(current, draftKey));530 updateComposerDrafts((current) => clearComposerDraftContentState(current, draftKey));
387}531}
pnpm-lock.yaml+26-29
...@@ -61,6 +61,8 @@ overrides:...@@ -61,6 +61,8 @@ overrides:
61 '@types/node': 24.12.461 '@types/node': 24.12.4
62 effect: 4.0.0-beta.7862 effect: 4.0.0-beta.78
63 expo-modules-jsi: 56.0.1063 expo-modules-jsi: 56.0.10
64 expo-sharing>@expo/config-plugins: 56.0.9
65 expo-sharing>@expo/config-types: 56.0.6
64 vite: npm:@voidzero-dev/vite-plus-core@0.2.266 vite: npm:@voidzero-dev/vite-plus-core@0.2.2
65 yaml: ^2.9.067 yaml: ^2.9.0
6668
...@@ -336,6 +338,9 @@ importers:...@@ -336,6 +338,9 @@ importers:
336 expo-secure-store:338 expo-secure-store:
337 specifier: ~56.0.4339 specifier: ~56.0.4
338 version: 56.0.4(expo@56.0.12)340 version: 56.0.4(expo@56.0.12)
341 expo-sharing:
342 specifier: ~56.0.18
343 version: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)
339 expo-splash-screen:344 expo-splash-screen:
340 specifier: ~56.0.10345 specifier: ~56.0.10
341 version: 56.0.10(expo@56.0.12)(typescript@6.0.3)346 version: 56.0.10(expo@56.0.12)(typescript@6.0.3)
...@@ -2422,15 +2427,9 @@ packages:...@@ -2422,15 +2427,9 @@ packages:
2422 '@expo/code-signing-certificates@0.0.6':2427 '@expo/code-signing-certificates@0.0.6':
2423 resolution: {integrity: sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w==}2428 resolution: {integrity: sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w==}
24242429
2425 '@expo/config-plugins@56.0.8':
2426 resolution: {integrity: sha512-phTuyBhgVLfqUHMjQkAfRtbyoY6yTxoKja1awtpVnEkoJDxPJuXx1KX5uvq1eZtt4bJQ08OBJ6P95INqRSHpRg==}
2427
2428 '@expo/config-plugins@56.0.9':2430 '@expo/config-plugins@56.0.9':
2429 resolution: {integrity: sha512-/6a/S9USwx8OC9tGjHxbviLFiBHyueN3aoNWMLvWDEJoZ1CIVW800ZBzwXq/FYNK2qzcN1LxFmQtzD1zeFQKNA==}2431 resolution: {integrity: sha512-/6a/S9USwx8OC9tGjHxbviLFiBHyueN3aoNWMLvWDEJoZ1CIVW800ZBzwXq/FYNK2qzcN1LxFmQtzD1zeFQKNA==}
24302432
2431 '@expo/config-types@56.0.5':
2432 resolution: {integrity: sha512-GsAHO/MwW9ZRdgnmyfRXqVGLCP/zejD6rWnp5OROp8mBGRObKm4HfrjlUyT1skjMwCj1OrURx9ZfIc6yeBAkIA==}
2433
2434 '@expo/config-types@56.0.6':2433 '@expo/config-types@56.0.6':
2435 resolution: {integrity: sha512-4Y6Aum5J4Re5NnxGVofRNe1aDwUBOmWhQYkynZsqzRtX/zEA1ADUeyHXuEckv9YD9djiyT7bKtLt5gKL3mA6VQ==}2434 resolution: {integrity: sha512-4Y6Aum5J4Re5NnxGVofRNe1aDwUBOmWhQYkynZsqzRtX/zEA1ADUeyHXuEckv9YD9djiyT7bKtLt5gKL3mA6VQ==}
24362435
...@@ -6725,6 +6724,13 @@ packages:...@@ -6725,6 +6724,13 @@ packages:
6725 resolution: {integrity: sha512-SmM2p2g3Jrktpiazcst+OxhjSzOHXKAY4BPURHYHXvApzzoybMmrNF4IEZ8DKZ145BhSe4ydAmlEFCRTsdtgUQ==}6724 resolution: {integrity: sha512-SmM2p2g3Jrktpiazcst+OxhjSzOHXKAY4BPURHYHXvApzzoybMmrNF4IEZ8DKZ145BhSe4ydAmlEFCRTsdtgUQ==}
6726 engines: {node: '>=20.16.0'}6725 engines: {node: '>=20.16.0'}
67276726
6727 expo-sharing@56.0.18:
6728 resolution: {integrity: sha512-45w4BWNFmdTczp+fJX6YfwJrn9sX+VeRWz2VWLhauygcCrym44HtVDXX5yVYPB9TW9ZesLcEI+CCrCBNWL7smQ==}
6729 peerDependencies:
6730 expo: '*'
6731 react: '*'
6732 react-native: '*'
6733
6728 expo-splash-screen@56.0.10:6734 expo-splash-screen@56.0.10:
6729 resolution: {integrity: sha512-vDIlo8hzt9HlCZQ0kSY66v83D1WEXOJbVMeyPDfXDu9tbDdPMNUyDpi4WGJXikAjxnAKfbt5Mv5NnEbxINy+VA==}6735 resolution: {integrity: sha512-vDIlo8hzt9HlCZQ0kSY66v83D1WEXOJbVMeyPDfXDu9tbDdPMNUyDpi4WGJXikAjxnAKfbt5Mv5NnEbxINy+VA==}
6730 peerDependencies:6736 peerDependencies:
...@@ -12217,25 +12223,6 @@ snapshots:...@@ -12217,25 +12223,6 @@ snapshots:
12217 dependencies:12223 dependencies:
12218 node-forge: 1.4.012224 node-forge: 1.4.0
1221912225
12220 '@expo/config-plugins@56.0.8(typescript@6.0.3)':
12221 dependencies:
12222 '@expo/config-types': 56.0.5
12223 '@expo/json-file': 10.2.0
12224 '@expo/plist': 0.7.0
12225 '@expo/require-utils': 56.1.3(typescript@6.0.3)
12226 '@expo/sdk-runtime-versions': 1.0.0
12227 chalk: 4.1.2
12228 debug: 4.4.3
12229 getenv: 2.0.0
12230 glob: 13.0.6
12231 semver: 7.8.5
12232 slugify: 1.6.9
12233 xcode: 3.0.1
12234 xml2js: 0.6.0
12235 transitivePeerDependencies:
12236 - supports-color
12237 - typescript
12238
12239 '@expo/config-plugins@56.0.9(typescript@6.0.3)':12226 '@expo/config-plugins@56.0.9(typescript@6.0.3)':
12240 dependencies:12227 dependencies:
12241 '@expo/config-types': 56.0.612228 '@expo/config-types': 56.0.6
...@@ -12255,14 +12242,12 @@ snapshots:...@@ -12255,14 +12242,12 @@ snapshots:
12255 - supports-color12242 - supports-color
12256 - typescript12243 - typescript
1225712244
12258 '@expo/config-types@56.0.5': {}
12259
12260 '@expo/config-types@56.0.6': {}12245 '@expo/config-types@56.0.6': {}
1226112246
12262 '@expo/config@56.0.9(typescript@6.0.3)':12247 '@expo/config@56.0.9(typescript@6.0.3)':
12263 dependencies:12248 dependencies:
12264 '@expo/config-plugins': 56.0.9(typescript@6.0.3)12249 '@expo/config-plugins': 56.0.9(typescript@6.0.3)
12265 '@expo/config-types': 56.0.512250 '@expo/config-types': 56.0.6
12266 '@expo/json-file': 10.2.012251 '@expo/json-file': 10.2.0
12267 '@expo/require-utils': 56.1.3(typescript@6.0.3)12252 '@expo/require-utils': 56.1.3(typescript@6.0.3)
12268 deepmerge: 4.3.112253 deepmerge: 4.3.1
...@@ -17035,9 +17020,21 @@ snapshots:...@@ -17035,9 +17020,21 @@ snapshots:
1703517020
17036 expo-server@56.0.5: {}17021 expo-server@56.0.5: {}
1703717022
17023 expo-sharing@56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3):
17024 dependencies:
17025 '@expo/config-plugins': 56.0.9(typescript@6.0.3)
17026 '@expo/config-types': 56.0.6
17027 '@expo/plist': 0.7.0
17028 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)
17029 react: 19.2.3
17030 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
17031 transitivePeerDependencies:
17032 - supports-color
17033 - typescript
17034
17038 expo-splash-screen@56.0.10(expo@56.0.12)(typescript@6.0.3):17035 expo-splash-screen@56.0.10(expo@56.0.12)(typescript@6.0.3):
17039 dependencies:17036 dependencies:
17040 '@expo/config-plugins': 56.0.8(typescript@6.0.3)17037 '@expo/config-plugins': 56.0.9(typescript@6.0.3)
17041 '@expo/image-utils': 0.10.1(typescript@6.0.3)17038 '@expo/image-utils': 0.10.1(typescript@6.0.3)
17042 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)17039 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)
17043 xml2js: 0.6.017040 xml2js: 0.6.0
pnpm-workspace.yaml+2
...@@ -86,6 +86,8 @@ overrides:...@@ -86,6 +86,8 @@ overrides:
86 "@types/node": "catalog:"86 "@types/node": "catalog:"
87 effect: "catalog:"87 effect: "catalog:"
88 expo-modules-jsi: 56.0.1088 expo-modules-jsi: 56.0.10
89 "expo-sharing>@expo/config-plugins": 56.0.9
90 "expo-sharing>@expo/config-types": 56.0.6
89 vite: "catalog:"91 vite: "catalog:"
90 yaml: "catalog:"92 yaml: "catalog:"
9193