| author | |
| committer | |
| log | fdca15471d92e95e4ec5501f45dbf3ce81f8d991 |
| tree | 4162c93f01e433f94eee7f6277b626709bf080fa |
| parent | 5d68fca8f210c137eee04da30ed25b5fa7e432d9 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | ``` |
| 36 | 36 | ||
| 37 | If your Xcode account only has a Personal Team, use a bundle identifier you control and opt into the | 37 | If your Xcode account only has a Personal Team, use a bundle identifier you control and opt into the |
| 38 | reduced-capability local build. Personal Team builds omit the widget extension, push entitlement, and | 38 | reduced-capability local build. Personal Team builds omit the widget and share extensions, push |
| 39 | native Sign in with Apple entitlement; builds without this opt-in are unchanged. | 39 | entitlement, and native Sign in with Apple entitlement; builds without this opt-in are unchanged. |
| 40 | 40 | ||
| 41 | ```bash | 41 | ```bash |
| 42 | T3CODE_IOS_PERSONAL_TEAM=1 \ | 42 | T3CODE_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 | } |
| 85 | 85 | ||
| 86 | const variant = VARIANT_CONFIG[APP_VARIANT]; | 86 | const variant = VARIANT_CONFIG[APP_VARIANT]; |
| 87 | const iosBundleIdentifier = isIosPersonalTeamBuild | ||
| 88 | ? personalTeamBundleIdentifier! | ||
| 89 | : variant.iosBundleIdentifier; | ||
| 87 | 90 | ||
| 88 | const dmSansFonts = { | 91 | const 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 = { |
| 94 | const widgetsPlugin: NonNullable<ExpoConfig["plugins"]>[number] = [ | 97 | const 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 the | 103 | // 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 | ]; |
| 113 | 116 | ||
| 117 | const 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 same | 141 | // 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 of | 142 | // 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 | |||
| 11 | const fs = require("fs"); | ||
| 12 | const path = require("path"); | ||
| 13 | const { withDangerousMod, withXcodeProject } = require("expo/config-plugins"); | ||
| 14 | |||
| 15 | const TARGET_NAME = "expo-sharing-extension"; | ||
| 16 | |||
| 17 | function stripComments(section) { | ||
| 18 | return Object.fromEntries( | ||
| 19 | Object.entries(section ?? {}).filter(([key]) => !key.endsWith("_comment")), | ||
| 20 | ); | ||
| 21 | } | ||
| 22 | |||
| 23 | function findByName(section, name) { | ||
| 24 | return Object.entries(stripComments(section)).find(([, value]) => value?.name === name) ?? null; | ||
| 25 | } | ||
| 26 | |||
| 27 | function escapeXml(value) { | ||
| 28 | return value | ||
| 29 | .replaceAll("&", "&amp;") | ||
| 30 | .replaceAll("<", "&lt;") | ||
| 31 | .replaceAll(">", "&gt;") | ||
| 32 | .replaceAll('"', "&quot;") | ||
| 33 | .replaceAll("'", "&apos;"); | ||
| 34 | } | ||
| 35 | |||
| 36 | function 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 | |||
| 60 | function 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 | |||
| 87 | module.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 |
| 11 | import { RegistryContext } from "@effect/atom-react"; | 11 | import { RegistryContext } from "@effect/atom-react"; |
| 12 | import { ConfirmDialogHost } from "./components/ConfirmDialogHost"; | 12 | import { ConfirmDialogHost } from "./components/ConfirmDialogHost"; |
| 13 | import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider"; | 13 | import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider"; |
| 14 | import { IncomingShareProvider } from "./features/sharing/IncomingShareProvider"; | ||
| 14 | import { AppearancePreferencesProvider } from "./features/settings/appearance/AppearancePreferencesProvider"; | 15 | import { AppearancePreferencesProvider } from "./features/settings/appearance/AppearancePreferencesProvider"; |
| 15 | import { RootStack } from "./Stack"; | 16 | import { RootStack } from "./Stack"; |
| 16 | import { appAtomRegistry } from "./state/atom-registry"; | 17 | import { 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 addresses | 27 | // <scheme>://expo-development-client/?url=<packager> — that URL addresses |
| 27 | // the launcher, not app navigation. Without this filter it falls through | 28 | // 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 | }; |
| 31 | 35 | ||
| 32 | const Navigation = createStaticNavigation(RootStack); | 36 | const 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 | <Navigation | 65 | <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 the | 73 | {/* 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"; |
| 13 | import { useEffect, useRef } from "react"; | ||
| 13 | import { DynamicColorIOS, Platform, Pressable, ScrollView, StyleSheet } from "react-native"; | 14 | import { DynamicColorIOS, Platform, Pressable, ScrollView, StyleSheet } from "react-native"; |
| 14 | import { useResolveClassNames } from "uniwind"; | 15 | import { useResolveClassNames } from "uniwind"; |
| 15 | 16 | ||
| ... | @@ -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"; |
| 54 | import { useAppShortcuts } from "./features/shortcuts/useAppShortcuts"; | 55 | import { useAppShortcuts } from "./features/shortcuts/useAppShortcuts"; |
| 56 | import { useIncomingShare } from "./features/sharing/IncomingShareProvider"; | ||
| 57 | import { | ||
| 58 | EMPTY_INCOMING_SHARE_PRESENTATION_STATE, | ||
| 59 | transitionIncomingSharePresentation, | ||
| 60 | } from "./features/sharing/incoming-share-presentation"; | ||
| 55 | import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; | 61 | import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; |
| 56 | import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; | 62 | import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; |
| 57 | 63 | ||
| ... | @@ -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; the | 308 | // 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 @@ | ||
| 1 | import Constants from "expo-constants"; | ||
| 2 | import * as Crypto from "expo-crypto"; | ||
| 3 | import { | ||
| 4 | clearSharedPayloads, | ||
| 5 | getResolvedSharedPayloadsAsync, | ||
| 6 | getSharedPayloads, | ||
| 7 | type ResolvedSharePayload, | ||
| 8 | type SharePayload, | ||
| 9 | } from "expo-sharing"; | ||
| 10 | import React, { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; | ||
| 11 | import { Alert, AppState, Platform } from "react-native"; | ||
| 12 | |||
| 13 | import { | ||
| 14 | buildIncomingShareDraft, | ||
| 15 | type IncomingShareDestination, | ||
| 16 | type IncomingShareDraft, | ||
| 17 | } from "./incoming-share-model"; | ||
| 18 | import { IncomingShareInbox } from "./incoming-share-inbox"; | ||
| 19 | import { | ||
| 20 | loadIncomingShareDrafts, | ||
| 21 | removeIncomingShareDraft, | ||
| 22 | writeIncomingShareDraft, | ||
| 23 | } from "./incoming-share-storage"; | ||
| 24 | |||
| 25 | type 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 | |||
| 39 | const IncomingShareContext = React.createContext<IncomingShareContextValue | null>(null); | ||
| 40 | |||
| 41 | function 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 | |||
| 51 | async 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 | |||
| 64 | async 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 | |||
| 76 | async function readBase64(uri: string): Promise<string> { | ||
| 77 | const { File } = await import("expo-file-system"); | ||
| 78 | return new File(uri).base64(); | ||
| 79 | } | ||
| 80 | |||
| 81 | async 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 | |||
| 96 | async 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. | ||
| 120 | const 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 | |||
| 161 | export 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 | |||
| 304 | export 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 @@ | ||
| 1 | import { describe, expect, it, vi } from "@effect/vitest"; | ||
| 2 | import type { SharePayload } from "expo-sharing"; | ||
| 3 | |||
| 4 | import type { IncomingShareDraft } from "./incoming-share-model"; | ||
| 5 | import { IncomingShareInbox, type IncomingShareInboxDependencies } from "./incoming-share-inbox"; | ||
| 6 | |||
| 7 | const PAYLOAD: SharePayload = { | ||
| 8 | shareType: "text", | ||
| 9 | mimeType: "text/plain", | ||
| 10 | value: "Fix this", | ||
| 11 | }; | ||
| 12 | |||
| 13 | function 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 | |||
| 24 | function deferred() { | ||
| 25 | let resolve!: () => void; | ||
| 26 | const promise = new Promise<void>((next) => { | ||
| 27 | resolve = next; | ||
| 28 | }); | ||
| 29 | return { promise, resolve }; | ||
| 30 | } | ||
| 31 | |||
| 32 | function 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 | |||
| 58 | describe("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 @@ | ||
| 1 | import type { SharePayload } from "expo-sharing"; | ||
| 2 | |||
| 3 | import { SerializedAsyncQueue } from "../../lib/serialized-async-queue"; | ||
| 4 | import { | ||
| 5 | hasIncomingShareContent, | ||
| 6 | type IncomingShareDestination, | ||
| 7 | type IncomingShareDraft, | ||
| 8 | } from "./incoming-share-model"; | ||
| 9 | |||
| 10 | export 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 | |||
| 31 | export 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 | */ | ||
| 50 | export 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 @@ | ||
| 1 | import { describe, expect, it, vi } from "@effect/vitest"; | ||
| 2 | import { | ||
| 3 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | ||
| 4 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import type { ResolvedSharePayload, SharePayload } from "expo-sharing"; | ||
| 7 | |||
| 8 | import { buildIncomingShareDraft, hasIncomingShareContent } from "./incoming-share-model"; | ||
| 9 | |||
| 10 | describe("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 @@ | ||
| 1 | import { | ||
| 2 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | ||
| 3 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, | ||
| 4 | } from "@t3tools/contracts"; | ||
| 5 | import * as Schema from "effect/Schema"; | ||
| 6 | import type { ResolvedSharePayload, SharePayload } from "expo-sharing"; | ||
| 7 | |||
| 8 | import { DraftComposerImageAttachmentSchema } from "../../lib/composer-image-schema"; | ||
| 9 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; | ||
| 10 | import { estimateBase64ByteSize } from "../../lib/base64"; | ||
| 11 | |||
| 12 | export 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 | |||
| 22 | export interface IncomingShareDestination { | ||
| 23 | readonly environmentId: string; | ||
| 24 | readonly projectId: string; | ||
| 25 | } | ||
| 26 | |||
| 27 | const IncomingShareDestinationSchema = Schema.Struct({ | ||
| 28 | environmentId: Schema.String, | ||
| 29 | projectId: Schema.String, | ||
| 30 | }); | ||
| 31 | |||
| 32 | export 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 | |||
| 42 | const decodeIncomingShareDraftSync = Schema.decodeUnknownSync(IncomingShareDraftSchema); | ||
| 43 | |||
| 44 | export function decodeIncomingShareDraft(value: unknown): IncomingShareDraft { | ||
| 45 | return decodeIncomingShareDraftSync(value); | ||
| 46 | } | ||
| 47 | |||
| 48 | export interface IncomingShareFileReader { | ||
| 49 | readonly readBase64: (uri: string) => Promise<string>; | ||
| 50 | readonly removeOwnedFile: (uri: string) => Promise<void> | void; | ||
| 51 | } | ||
| 52 | |||
| 53 | function 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 | |||
| 70 | function 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 | |||
| 98 | async 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 | |||
| 112 | function 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 | |||
| 125 | export 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 | |||
| 215 | export 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 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | EMPTY_INCOMING_SHARE_PRESENTATION_STATE, | ||
| 5 | transitionIncomingSharePresentation, | ||
| 6 | } from "./incoming-share-presentation"; | ||
| 7 | |||
| 8 | describe("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 @@ | ||
| 1 | export interface IncomingSharePresentationState { | ||
| 2 | readonly presentedShareId: string | null; | ||
| 3 | readonly dismissedShareId: string | null; | ||
| 4 | } | ||
| 5 | |||
| 6 | export interface IncomingSharePresentationTransition { | ||
| 7 | readonly state: IncomingSharePresentationState; | ||
| 8 | readonly shareIdToPresent: string | null; | ||
| 9 | } | ||
| 10 | |||
| 11 | export 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 | */ | ||
| 20 | export 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 @@ | ||
| 1 | import * as Schema from "effect/Schema"; | ||
| 2 | |||
| 3 | import { decodeIncomingShareDraft, type IncomingShareDraft } from "./incoming-share-model"; | ||
| 4 | |||
| 5 | const INCOMING_SHARE_DIRECTORY = "incoming-shares"; | ||
| 6 | |||
| 7 | export 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 | |||
| 20 | function fileName(shareId: string): string { | ||
| 21 | return `${encodeURIComponent(shareId)}.json`; | ||
| 22 | } | ||
| 23 | |||
| 24 | async 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 | |||
| 31 | async function getFile(shareId: string) { | ||
| 32 | const { File } = await import("expo-file-system"); | ||
| 33 | return new File(await getDirectory(), fileName(shareId)); | ||
| 34 | } | ||
| 35 | |||
| 36 | export 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 | |||
| 59 | export 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 | |||
| 71 | export 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 | }; |
| 13 | 14 | ||
| 14 | export function NewTaskDraftRouteScreen({ route }: StaticScreenProps<NewTaskDraftRouteParams>) { | 15 | export 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 | <NewTaskDraftScreen | 38 | <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.pendingTaskId | 44 | 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 @@ |
| 1 | import { NativeStackScreenOptions } from "../../native/StackHeader"; | 1 | import { NativeStackScreenOptions } from "../../native/StackHeader"; |
| 2 | import { StackActions, useNavigation } from "@react-navigation/native"; | 2 | import { StackActions, useNavigation, usePreventRemove } from "@react-navigation/native"; |
| 3 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 3 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 4 | import { Alert, InteractionManager, Platform, View, useColorScheme } from "react-native"; | 4 | import { Alert, InteractionManager, Platform, View, useColorScheme } from "react-native"; |
| 5 | import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller"; | 5 | import { 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"; |
| 37 | import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; | 37 | import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; |
| 38 | import { getComposerDraftSnapshot } from "../../state/use-composer-drafts"; | 38 | import { |
| 39 | clearComposerDraftContent, | ||
| 40 | getComposerDraftSnapshot, | ||
| 41 | mergeComposerDraftContent, | ||
| 42 | restoreComposerDraftSnapshot, | ||
| 43 | type ComposerDraft, | ||
| 44 | } from "../../state/use-composer-drafts"; | ||
| 39 | import { useProjects } from "../../state/entities"; | 45 | import { useProjects } from "../../state/entities"; |
| 40 | import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn"; | 46 | import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn"; |
| 41 | import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; | 47 | import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; |
| ... | @@ -43,6 +49,7 @@ import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../sta | ... | @@ -43,6 +49,7 @@ import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../sta |
| 43 | import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry"; | 49 | import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry"; |
| 44 | import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; | 50 | import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; |
| 45 | import { useCreateProjectThread } from "./use-project-actions"; | 51 | import { useCreateProjectThread } from "./use-project-actions"; |
| 52 | import { useIncomingShare } from "../sharing/IncomingShareProvider"; | ||
| 46 | 53 | ||
| 47 | function formatWorkspaceLabel(input: { | 54 | function 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 | }, []); |
| 91 | 191 | ||
| ... | @@ -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 | } |
| 167 | 275 | ||
| 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]); |
| 200 | 309 | ||
| 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 | ); |
| 231 | 540 | ||
| 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 | } |
| 399 | 708 | ||
| 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 | } |
| 406 | 715 | ||
| 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 | } |
| 425 | 737 | ||
| 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 | } |
| 445 | 760 | ||
| 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 | <ComposerEditor | 944 | <ComposerEditor |
| 627 | ref={promptInputRef} | 945 | ref={promptInputRef} |
| 628 | autoFocus={!isAndroid} | 946 | autoFocus={!isAndroid} |
| 947 | editable={!isIncomingShareTransferPending} | ||
| 629 | multiline | 948 | 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 | <ControlPillMenu | 984 | <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 | <ComposerToolbarTrigger | 988 | <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 | <ComposerToolbarTrigger | 999 | <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 | <ComposerToolbarTrigger | 1010 | <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 | <ComposerToolbarTrigger | 1021 | <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 | <ComposerAttachmentStrip | 1088 | <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 | <ComposerAttachmentStrip | 1134 | <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 @@ |
| 1 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; | 1 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 2 | import { useNavigation } from "@react-navigation/native"; | 2 | import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 3 | import { SymbolView } from "../../components/AppSymbol"; | 3 | import { SymbolView } from "../../components/AppSymbol"; |
| 4 | import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; | 4 | import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; |
| 5 | import { useMemo } from "react"; | 5 | import { useEffect, useMemo, useRef } from "react"; |
| 6 | import { ActivityIndicator, Platform, Pressable, ScrollView, View } from "react-native"; | 6 | import { ActivityIndicator, Alert, Platform, Pressable, ScrollView, View } from "react-native"; |
| 7 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 7 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 8 | import { useThemeColor } from "../../lib/useThemeColor"; | 8 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 9 | import { cn } from "../../lib/cn"; | 9 | import { cn } from "../../lib/cn"; |
| ... | @@ -16,6 +16,11 @@ import type { WorkspaceState } from "../../state/workspaceModel"; | ... | @@ -16,6 +16,11 @@ import type { WorkspaceState } from "../../state/workspaceModel"; |
| 16 | import { useWorkspaceState } from "../../state/workspace"; | 16 | import { useWorkspaceState } from "../../state/workspace"; |
| 17 | import { groupProjectsByRepository } from "../../lib/repositoryGroups"; | 17 | import { groupProjectsByRepository } from "../../lib/repositoryGroups"; |
| 18 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; | 18 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; |
| 19 | import { useIncomingShare } from "../sharing/IncomingShareProvider"; | ||
| 20 | |||
| 21 | type NewTaskRouteParams = { | ||
| 22 | readonly incomingShareId?: string | string[]; | ||
| 23 | }; | ||
| 19 | 24 | ||
| 20 | function deriveProjectEmptyState(catalogState: WorkspaceState): { | 25 | function 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 | } |
| 74 | 79 | ||
| 75 | export function NewTaskRouteScreen() { | 80 | export 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]); | ||
| 112 | 195 | ||
| 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 | <AndroidScreenHeader | 202 | <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.Button | 232 | <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 | separateBackground | 235 | separateBackground |
| 139 | /> | 236 | /> |
| 140 | ) : null} | 237 | </NativeHeaderToolbar> |
| 141 | <NativeHeaderToolbar.Button | 238 | </> |
| 142 | icon="plus" | ||
| 143 | onPress={() => navigation.navigate("NewTaskSheet", { screen: "AddProject" })} | ||
| 144 | separateBackground | ||
| 145 | /> | ||
| 146 | </NativeHeaderToolbar> | ||
| 147 | )} | 239 | )} |
| 148 | 240 | ||
| 149 | <ScrollView | 241 | <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 | <Pressable | 288 | <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 @@ | ||
| 1 | export 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"; |
| 6 | import { estimateBase64ByteSize } from "./base64"; | ||
| 6 | import { uuidv4 } from "./uuid"; | 7 | import { uuidv4 } from "./uuid"; |
| 7 | 8 | ||
| 8 | export interface DraftComposerImageAttachment extends UploadChatImageAttachment { | 9 | export interface DraftComposerImageAttachment extends UploadChatImageAttachment { |
| ... | @@ -25,11 +26,6 @@ export function toUploadChatImageAttachments( | ... | @@ -25,11 +26,6 @@ export function toUploadChatImageAttachments( |
| 25 | 26 | ||
| 26 | const OWNED_PASTED_IMAGE_DIRECTORY = "t3-composer-paste"; | 27 | const OWNED_PASTED_IMAGE_DIRECTORY = "t3-composer-paste"; |
| 27 | 28 | ||
| 28 | function 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 | |||
| 33 | async function loadImagePicker() { | 29 | async 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 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { SerializedAsyncQueue } from "./serialized-async-queue"; | ||
| 4 | |||
| 5 | function deferred() { | ||
| 6 | let resolve!: () => void; | ||
| 7 | const promise = new Promise<void>((next) => { | ||
| 8 | resolve = next; | ||
| 9 | }); | ||
| 10 | return { promise, resolve }; | ||
| 11 | } | ||
| 12 | |||
| 13 | describe("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 | */ | ||
| 5 | export 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.accessibilityLabel | 201 | ? 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"; |
| 13 | 15 | ||
| 14 | const DRAFT: ComposerDraft = { | 16 | const 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", () => { |
| 108 | 111 | ||
| 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 | }); |
| 133 | 137 | ||
| 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 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { | 2 | import { |
| 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"; |
| 14 | 15 | ||
| 15 | import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema"; | 16 | import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema"; |
| 16 | import type { DraftComposerImageAttachment } from "../lib/composerImages"; | 17 | import type { DraftComposerImageAttachment } from "../lib/composerImages"; |
| 18 | import { SerializedAsyncQueue } from "../lib/serialized-async-queue"; | ||
| 17 | import { appAtomRegistry } from "./atom-registry"; | 19 | import { appAtomRegistry } from "./atom-registry"; |
| 18 | 20 | ||
| 19 | const COMPOSER_DRAFTS_SCHEMA_VERSION = 1; | 21 | const 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 |
| 38 | export interface ComposerDraft { | 40 | export 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 | } |
| 46 | 49 | ||
| 50 | export interface ComposerDraftContent { | ||
| 51 | readonly text: string; | ||
| 52 | readonly attachments: ReadonlyArray<DraftComposerImageAttachment>; | ||
| 53 | readonly sourceShareId?: string; | ||
| 54 | } | ||
| 55 | |||
| 47 | export interface ComposerDraftWorkspaceSelection { | 56 | export 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({ |
| 66 | const ComposerDraftSchema = Schema.Struct({ | 75 | const 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 |
| 93 | 103 | ||
| 94 | let loadPromise: Promise<void> | null = null; | 104 | let loadPromise: Promise<void> | null = null; |
| 95 | let persistTimer: ReturnType<typeof setTimeout> | null = null; | 105 | let persistTimer: ReturnType<typeof setTimeout> | null = null; |
| 106 | const persistenceQueue = new SerializedAsyncQueue(); | ||
| 96 | 107 | ||
| 97 | function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft { | 108 | function 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 |
| 193 | 204 | ||
| 194 | async function savePersistedComposerDrafts(drafts: Record<string, ComposerDraft>): Promise<void> { | 205 | async 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 | } |
| 384 | 396 | ||
| 397 | export 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 | |||
| 411 | function 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 | |||
| 426 | export 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 | */ | ||
| 473 | export 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. */ | ||
| 508 | export 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 | |||
| 385 | export function clearComposerDraftContent(draftKey: string): void { | 529 | export 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.4 | 61 | '@types/node': 24.12.4 |
| 62 | effect: 4.0.0-beta.78 | 62 | effect: 4.0.0-beta.78 |
| 63 | expo-modules-jsi: 56.0.10 | 63 | 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.2 | 66 | vite: npm:@voidzero-dev/vite-plus-core@0.2.2 |
| 65 | yaml: ^2.9.0 | 67 | yaml: ^2.9.0 |
| 66 | 68 | ||
| ... | @@ -336,6 +338,9 @@ importers: | ... | @@ -336,6 +338,9 @@ importers: |
| 336 | expo-secure-store: | 338 | expo-secure-store: |
| 337 | specifier: ~56.0.4 | 339 | 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.10 | 345 | 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==} |
| 2424 | 2429 | ||
| 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==} |
| 2430 | 2432 | ||
| 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==} |
| 2436 | 2435 | ||
| ... | @@ -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'} |
| 6727 | 6726 | ||
| 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.0 | 12224 | node-forge: 1.4.0 |
| 12219 | 12225 | ||
| 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.6 | 12228 | '@expo/config-types': 56.0.6 |
| ... | @@ -12255,14 +12242,12 @@ snapshots: | ... | @@ -12255,14 +12242,12 @@ snapshots: |
| 12255 | - supports-color | 12242 | - supports-color |
| 12256 | - typescript | 12243 | - typescript |
| 12257 | 12244 | ||
| 12258 | '@expo/config-types@56.0.5': {} | ||
| 12259 | |||
| 12260 | '@expo/config-types@56.0.6': {} | 12245 | '@expo/config-types@56.0.6': {} |
| 12261 | 12246 | ||
| 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.5 | 12250 | '@expo/config-types': 56.0.6 |
| 12266 | '@expo/json-file': 10.2.0 | 12251 | '@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.1 | 12253 | deepmerge: 4.3.1 |
| ... | @@ -17035,9 +17020,21 @@ snapshots: | ... | @@ -17035,9 +17020,21 @@ snapshots: |
| 17035 | 17020 | ||
| 17036 | expo-server@56.0.5: {} | 17021 | expo-server@56.0.5: {} |
| 17037 | 17022 | ||
| 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.0 | 17040 | 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.10 | 88 | 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:" |
| 91 | 93 |