| 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 | 35 | ``` |
| 36 | 36 | |
| 37 | 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 | |
| 39 | native Sign in with Apple entitlement; builds without this opt-in are unchanged. | |
| 38 | reduced-capability local build. Personal Team builds omit the widget and share extensions, push | |
| 39 | entitlement, and native Sign in with Apple entitlement; builds without this opt-in are unchanged. | |
| 40 | 40 | |
| 41 | 41 | ```bash |
| 42 | 42 | T3CODE_IOS_PERSONAL_TEAM=1 \ |
apps/mobile/app.config.ts+33-3| ... | ... | @@ -84,6 +84,9 @@ function resolveAppVariant(value: string | undefined): AppVariant { |
| 84 | 84 | } |
| 85 | 85 | |
| 86 | 86 | const variant = VARIANT_CONFIG[APP_VARIANT]; |
| 87 | const iosBundleIdentifier = isIosPersonalTeamBuild | |
| 88 | ? personalTeamBundleIdentifier! | |
| 89 | : variant.iosBundleIdentifier; | |
| 87 | 90 | |
| 88 | 91 | const dmSansFonts = { |
| 89 | 92 | regular: "@expo-google-fonts/dm-sans/400Regular/DMSans_400Regular.ttf", |
| ... | ... | @@ -94,8 +97,8 @@ const dmSansFonts = { |
| 94 | 97 | const widgetsPlugin: NonNullable<ExpoConfig["plugins"]>[number] = [ |
| 95 | 98 | "expo-widgets", |
| 96 | 99 | { |
| 97 | bundleIdentifier: `${variant.iosBundleIdentifier}.widgets`, | |
| 98 | groupIdentifier: `group.${variant.iosBundleIdentifier}`, | |
| 100 | bundleIdentifier: `${iosBundleIdentifier}.widgets`, | |
| 101 | groupIdentifier: `group.${iosBundleIdentifier}`, | |
| 99 | 102 | enablePushNotifications: true, |
| 100 | 103 | // Agent activity can update many times an hour; without the |
| 101 | 104 | // frequent-updates entitlement iOS throttles the update budget sooner. |
| ... | ... | @@ -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 | 141 | // These aliases match the fonts' PostScript names on iOS. Register the same |
| 115 | 142 | // names on Android so React Native and the native composer use one set of |
| 116 | 143 | // family names without waiting for runtime font loading. |
| ... | ... | @@ -140,7 +167,7 @@ const config: ExpoConfig = { |
| 140 | 167 | ios: { |
| 141 | 168 | icon: variant.assets.iosIcon, |
| 142 | 169 | supportsTablet: true, |
| 143 | bundleIdentifier: variant.iosBundleIdentifier, | |
| 170 | bundleIdentifier: iosBundleIdentifier, | |
| 144 | 171 | // Pin code signing to the T3 Tools team so non-interactive `expo run:ios` |
| 145 | 172 | // does not fall back to a personal team (which cannot sign app groups, |
| 146 | 173 | // Sign in with Apple, or push notification entitlements). |
| ... | ... | @@ -202,6 +229,9 @@ const config: ExpoConfig = { |
| 202 | 229 | ], |
| 203 | 230 | "expo-secure-store", |
| 204 | 231 | "expo-sqlite", |
| 232 | ...(isIosPersonalTeamBuild | |
| 233 | ? [sharingPlugin] | |
| 234 | : ["./plugins/withShareExtensionDisplayName.cjs", sharingPlugin]), | |
| 205 | 235 | [ |
| 206 | 236 | "expo-notifications", |
| 207 | 237 | { |
apps/mobile/package.json+1| ... | ... | @@ -91,6 +91,7 @@ |
| 91 | 91 | "expo-paste-input": "^0.1.15", |
| 92 | 92 | "expo-quick-actions": "^6.0.2", |
| 93 | 93 | "expo-secure-store": "~56.0.4", |
| 94 | "expo-sharing": "~56.0.18", | |
| 94 | 95 | "expo-splash-screen": "~56.0.10", |
| 95 | 96 | "expo-sqlite": "~56.0.5", |
| 96 | 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 | 11 | import { RegistryContext } from "@effect/atom-react"; |
| 12 | 12 | import { ConfirmDialogHost } from "./components/ConfirmDialogHost"; |
| 13 | 13 | import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider"; |
| 14 | import { IncomingShareProvider } from "./features/sharing/IncomingShareProvider"; | |
| 14 | 15 | import { AppearancePreferencesProvider } from "./features/settings/appearance/AppearancePreferencesProvider"; |
| 15 | 16 | import { RootStack } from "./Stack"; |
| 16 | 17 | import { appAtomRegistry } from "./state/atom-registry"; |
| ... | ... | @@ -26,7 +27,10 @@ const appLinking = { |
| 26 | 27 | // <scheme>://expo-development-client/?url=<packager> — that URL addresses |
| 27 | 28 | // the launcher, not app navigation. Without this filter it falls through |
| 28 | 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 | 36 | const Navigation = createStaticNavigation(RootStack); |
| ... | ... | @@ -58,10 +62,12 @@ export default function App() { |
| 58 | 62 | the system is in dark mode. */} |
| 59 | 63 | {/* Blur target for Android dropdown backdrops — see appBlurTarget.ts. */} |
| 60 | 64 | <BlurTargetView ref={appBlurTargetRef} style={{ flex: 1 }}> |
| 61 | <Navigation | |
| 62 | linking={appLinking} | |
| 63 | theme={colorScheme === "dark" ? DarkTheme : DefaultTheme} | |
| 64 | /> | |
| 65 | <IncomingShareProvider> | |
| 66 | <Navigation | |
| 67 | linking={appLinking} | |
| 68 | theme={colorScheme === "dark" ? DarkTheme : DefaultTheme} | |
| 69 | /> | |
| 70 | </IncomingShareProvider> | |
| 65 | 71 | <ConfirmDialogHost /> |
| 66 | 72 | </BlurTargetView> |
| 67 | 73 | {/* Anchored-menu overlays render here — in-window, so the |
apps/mobile/src/Stack.tsx+24| ... | ... | @@ -10,6 +10,7 @@ import { |
| 10 | 10 | createNativeStackScreen, |
| 11 | 11 | type NativeStackNavigationOptions, |
| 12 | 12 | } from "@react-navigation/native-stack"; |
| 13 | import { useEffect, useRef } from "react"; | |
| 13 | 14 | import { DynamicColorIOS, Platform, Pressable, ScrollView, StyleSheet } from "react-native"; |
| 14 | 15 | import { useResolveClassNames } from "uniwind"; |
| 15 | 16 | |
| ... | ... | @@ -52,6 +53,11 @@ import { |
| 52 | 53 | SettingsLegalDocumentExternalHeaderButton, |
| 53 | 54 | } from "./features/settings/components/SettingsLegalDocumentRouteScreen"; |
| 54 | 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 | 61 | import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; |
| 56 | 62 | import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; |
| 57 | 63 | |
| ... | ... | @@ -275,12 +281,30 @@ function RootStackLayout(props: { |
| 275 | 281 | readonly children: React.ReactNode; |
| 276 | 282 | readonly state: NavigationState; |
| 277 | 283 | }) { |
| 284 | const navigation = useNavigation(); | |
| 285 | const { pendingShare } = useIncomingShare(); | |
| 286 | const sharePresentationRef = useRef(EMPTY_INCOMING_SHARE_PRESENTATION_STATE); | |
| 278 | 287 | useAgentNotificationNavigation(); |
| 279 | 288 | useThreadOutboxDrain(); |
| 280 | 289 | // Presents the T3 Connect onboarding sheet after an in-session sign-in. |
| 281 | 290 | useConnectOnboardingNavigation(); |
| 282 | 291 | // Launcher app shortcuts: routes shortcut taps and tracks opened threads. |
| 283 | 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 | 308 | // Full pathname (sheets included) for keyboard-command scoping; the |
| 285 | 309 | // workspace layout only reacts to the underlying non-overlay route. |
| 286 | 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 | 9 | readonly projectId?: string | string[]; |
| 10 | 10 | readonly title?: string | string[]; |
| 11 | 11 | readonly pendingTaskId?: string | string[]; |
| 12 | readonly incomingShareId?: string | string[]; | |
| 12 | 13 | }; |
| 13 | 14 | |
| 14 | 15 | export function NewTaskDraftRouteScreen({ route }: StaticScreenProps<NewTaskDraftRouteParams>) { |
| ... | ... | @@ -36,6 +37,9 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps<NewTaskDraf |
| 36 | 37 | /> |
| 37 | 38 | <NewTaskDraftScreen |
| 38 | 39 | initialProjectRef={initialProjectRef} |
| 40 | incomingShareId={ | |
| 41 | Array.isArray(params.incomingShareId) ? params.incomingShareId[0] : params.incomingShareId | |
| 42 | } | |
| 39 | 43 | pendingTaskId={ |
| 40 | 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 | 1 | import { NativeStackScreenOptions } from "../../native/StackHeader"; |
| 2 | import { StackActions, useNavigation } from "@react-navigation/native"; | |
| 2 | import { StackActions, useNavigation, usePreventRemove } from "@react-navigation/native"; | |
| 3 | 3 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 4 | 4 | import { Alert, InteractionManager, Platform, View, useColorScheme } from "react-native"; |
| 5 | 5 | import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller"; |
| ... | ... | @@ -35,7 +35,13 @@ import { |
| 35 | 35 | resolveProviderOptionDescriptors, |
| 36 | 36 | } from "../../lib/providerOptions"; |
| 37 | 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 | 45 | import { useProjects } from "../../state/entities"; |
| 40 | 46 | import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn"; |
| 41 | 47 | import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; |
| ... | ... | @@ -43,6 +49,7 @@ import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../sta |
| 43 | 49 | import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry"; |
| 44 | 50 | import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; |
| 45 | 51 | import { useCreateProjectThread } from "./use-project-actions"; |
| 52 | import { useIncomingShare } from "../sharing/IncomingShareProvider"; | |
| 46 | 53 | |
| 47 | 54 | function formatWorkspaceLabel(input: { |
| 48 | 55 | readonly workspaceMode: string; |
| ... | ... | @@ -63,11 +70,20 @@ export function NewTaskDraftScreen(props: { |
| 63 | 70 | }; |
| 64 | 71 | /** Queued outbox message id when editing an existing pending task. */ |
| 65 | 72 | readonly pendingTaskId?: string; |
| 73 | /** Durable native share inbox item to merge into this project draft. */ | |
| 74 | readonly incomingShareId?: string; | |
| 66 | 75 | }) { |
| 67 | 76 | const projects = useProjects(); |
| 68 | 77 | const createProjectThread = useCreateProjectThread(); |
| 69 | 78 | const flow = useNewTaskFlow(); |
| 70 | 79 | const navigation = useNavigation(); |
| 80 | const { | |
| 81 | consumeShare, | |
| 82 | getShare, | |
| 83 | isLoading: isIncomingShareInboxLoading, | |
| 84 | releaseShareReservation, | |
| 85 | reserveShare, | |
| 86 | } = useIncomingShare(); | |
| 71 | 87 | const insets = useSafeAreaInsets(); |
| 72 | 88 | const colorScheme = useColorScheme(); |
| 73 | 89 | const isKeyboardVisible = useKeyboardState((state) => state.isVisible); |
| ... | ... | @@ -82,10 +98,94 @@ export function NewTaskDraftScreen(props: { |
| 82 | 98 | const promptInputRef = useRef<ComposerEditorHandle>(null); |
| 83 | 99 | const loadedBranchesProjectKeyRef = useRef<string | null>(null); |
| 84 | 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 | 151 | const appliedInitialProjectKeyRef = useRef<string | null>(null); |
| 86 | 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 | 184 | return () => { |
| 88 | 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 | 263 | setProject(directProject); |
| 164 | 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 | 276 | if (selectedProject) { |
| ... | ... | @@ -179,6 +287,7 @@ export function NewTaskDraftScreen(props: { |
| 179 | 287 | logicalProjects, |
| 180 | 288 | projects, |
| 181 | 289 | props.initialProjectRef, |
| 290 | props.incomingShareId, | |
| 182 | 291 | props.pendingTaskId, |
| 183 | 292 | navigation, |
| 184 | 293 | selectedProject, |
| ... | ... | @@ -198,6 +307,205 @@ export function NewTaskDraftScreen(props: { |
| 198 | 307 | void flow.loadBranches(); |
| 199 | 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 | 509 | useEffect(() => { |
| 202 | 510 | // Android starts with the collapsed composer pill (like an open thread) |
| 203 | 511 | // and only expands/focuses when tapped. |
| ... | ... | @@ -223,10 +531,11 @@ export function NewTaskDraftScreen(props: { |
| 223 | 531 | flow.environments.map((environment) => ({ |
| 224 | 532 | id: `environment:${environment.environmentId}`, |
| 225 | 533 | title: environment.environmentLabel, |
| 534 | attributes: isIncomingShareTransferPending ? { disabled: true } : undefined, | |
| 226 | 535 | state: |
| 227 | 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 | 541 | const modelMenuActions = useMemo( |
| ... | ... | @@ -391,20 +700,23 @@ export function NewTaskDraftScreen(props: { |
| 391 | 700 | [currentBranchName, flow.selectedBranchName, flow.workspaceMode], |
| 392 | 701 | ); |
| 393 | 702 | function handleModelMenuAction(event: string) { |
| 394 | if (!event.startsWith("model:")) { | |
| 703 | if (isIncomingShareTransferPending || !event.startsWith("model:")) { | |
| 395 | 704 | return; |
| 396 | 705 | } |
| 397 | 706 | flow.setSelectedModelKey(event.slice("model:".length)); |
| 398 | 707 | } |
| 399 | 708 | |
| 400 | 709 | function handleEnvironmentMenuAction(event: string) { |
| 401 | if (!event.startsWith("environment:")) { | |
| 710 | if (isIncomingShareTransferPending || !event.startsWith("environment:")) { | |
| 402 | 711 | return; |
| 403 | 712 | } |
| 404 | 713 | flow.selectEnvironment(EnvironmentId.make(event.slice("environment:".length))); |
| 405 | 714 | } |
| 406 | 715 | |
| 407 | 716 | function handleOptionsMenuAction(event: string) { |
| 717 | if (isIncomingShareTransferPending) { | |
| 718 | return; | |
| 719 | } | |
| 408 | 720 | const providerOptions = applyProviderOptionMenuEvent(providerOptionDescriptors, event); |
| 409 | 721 | if (providerOptions) { |
| 410 | 722 | flow.setSelectedModelOptions(providerOptions); |
| ... | ... | @@ -424,6 +736,9 @@ export function NewTaskDraftScreen(props: { |
| 424 | 736 | } |
| 425 | 737 | |
| 426 | 738 | function handleWorkspaceMenuAction(event: string) { |
| 739 | if (isIncomingShareTransferPending) { | |
| 740 | return; | |
| 741 | } | |
| 427 | 742 | if (event.startsWith("workspace:mode:")) { |
| 428 | 743 | flow.setWorkspaceMode( |
| 429 | 744 | event.slice("workspace:mode:".length) as Parameters<typeof flow.setWorkspaceMode>[0], |
| ... | ... | @@ -444,6 +759,9 @@ export function NewTaskDraftScreen(props: { |
| 444 | 759 | } |
| 445 | 760 | |
| 446 | 761 | async function handlePickImages(): Promise<void> { |
| 762 | if (isIncomingShareTransferPending) { | |
| 763 | return; | |
| 764 | } | |
| 447 | 765 | const result = await pickComposerImages({ existingCount: flow.attachments.length }); |
| 448 | 766 | if (result.images.length > 0) { |
| 449 | 767 | flow.appendAttachments(result.images); |
| ... | ... | @@ -526,8 +844,7 @@ export function NewTaskDraftScreen(props: { |
| 526 | 844 | if (editingPendingTask) { |
| 527 | 845 | flow.finishEditingPendingTask(); |
| 528 | 846 | } else { |
| 529 | flow.setPrompt(""); | |
| 530 | flow.clearAttachments(); | |
| 847 | clearComposerDraftContent(draftKey); | |
| 531 | 848 | } |
| 532 | 849 | navigation.getParent()?.goBack(); |
| 533 | 850 | return; |
| ... | ... | @@ -585,8 +902,7 @@ export function NewTaskDraftScreen(props: { |
| 585 | 902 | } |
| 586 | 903 | flow.finishEditingPendingTask(); |
| 587 | 904 | } else { |
| 588 | flow.setPrompt(""); | |
| 589 | flow.clearAttachments(); | |
| 905 | clearComposerDraftContent(draftKey); | |
| 590 | 906 | } |
| 591 | 907 | navigation.dispatch( |
| 592 | 908 | StackActions.replace("Thread", { |
| ... | ... | @@ -620,12 +936,15 @@ export function NewTaskDraftScreen(props: { |
| 620 | 936 | Boolean(flow.selectedProject) && |
| 621 | 937 | Boolean(flow.selectedModel) && |
| 622 | 938 | flow.prompt.trim().length > 0 && |
| 939 | isIncomingShareReady && | |
| 940 | !isImportingShare && | |
| 623 | 941 | !flow.submitting && |
| 624 | 942 | !(flow.workspaceMode === "worktree" && !flow.selectedBranchName); |
| 625 | 943 | const promptEditor = ( |
| 626 | 944 | <ComposerEditor |
| 627 | 945 | ref={promptInputRef} |
| 628 | 946 | autoFocus={!isAndroid} |
| 947 | editable={!isIncomingShareTransferPending} | |
| 629 | 948 | multiline |
| 630 | 949 | scrollEnabled={isExpanded} |
| 631 | 950 | value={flow.prompt} |
| ... | ... | @@ -660,6 +979,7 @@ export function NewTaskDraftScreen(props: { |
| 660 | 979 | icon="plus" |
| 661 | 980 | onPress={() => void handlePickImages()} |
| 662 | 981 | showChevron={false} |
| 982 | disabled={isIncomingShareTransferPending} | |
| 663 | 983 | /> |
| 664 | 984 | <ControlPillMenu |
| 665 | 985 | actions={modelMenuActions} |
| ... | ... | @@ -667,6 +987,7 @@ export function NewTaskDraftScreen(props: { |
| 667 | 987 | > |
| 668 | 988 | <ComposerToolbarTrigger |
| 669 | 989 | accessibilityLabel="Model" |
| 990 | disabled={isIncomingShareTransferPending} | |
| 670 | 991 | iconNode={<ProviderIcon provider={flow.selectedModelOption?.providerDriver} size={16} />} |
| 671 | 992 | label={flow.selectedModelOption?.label ?? "Model"} |
| 672 | 993 | /> |
| ... | ... | @@ -677,6 +998,7 @@ export function NewTaskDraftScreen(props: { |
| 677 | 998 | > |
| 678 | 999 | <ComposerToolbarTrigger |
| 679 | 1000 | accessibilityLabel="Configuration" |
| 1001 | disabled={isIncomingShareTransferPending} | |
| 680 | 1002 | icon="slider.horizontal.3" |
| 681 | 1003 | label={configurationLabel} |
| 682 | 1004 | /> |
| ... | ... | @@ -687,6 +1009,7 @@ export function NewTaskDraftScreen(props: { |
| 687 | 1009 | > |
| 688 | 1010 | <ComposerToolbarTrigger |
| 689 | 1011 | accessibilityLabel="Environment" |
| 1012 | disabled={isIncomingShareTransferPending} | |
| 690 | 1013 | icon="desktopcomputer" |
| 691 | 1014 | label={selectedEnvironmentLabel} |
| 692 | 1015 | /> |
| ... | ... | @@ -697,6 +1020,7 @@ export function NewTaskDraftScreen(props: { |
| 697 | 1020 | > |
| 698 | 1021 | <ComposerToolbarTrigger |
| 699 | 1022 | accessibilityLabel="Workspace" |
| 1023 | disabled={isIncomingShareTransferPending} | |
| 700 | 1024 | icon="point.topleft.down.curvedto.point.bottomright.up" |
| 701 | 1025 | label={workspaceLabel} |
| 702 | 1026 | /> |
| ... | ... | @@ -763,7 +1087,9 @@ export function NewTaskDraftScreen(props: { |
| 763 | 1087 | <View className="pb-2.5"> |
| 764 | 1088 | <ComposerAttachmentStrip |
| 765 | 1089 | attachments={flow.attachments} |
| 766 | onRemove={flow.removeAttachment} | |
| 1090 | onRemove={ | |
| 1091 | isIncomingShareTransferPending ? () => undefined : flow.removeAttachment | |
| 1092 | } | |
| 767 | 1093 | /> |
| 768 | 1094 | </View> |
| 769 | 1095 | ) : null} |
| ... | ... | @@ -807,7 +1133,7 @@ export function NewTaskDraftScreen(props: { |
| 807 | 1133 | <View className="px-4 pt-3"> |
| 808 | 1134 | <ComposerAttachmentStrip |
| 809 | 1135 | attachments={flow.attachments} |
| 810 | onRemove={flow.removeAttachment} | |
| 1136 | onRemove={isIncomingShareTransferPending ? () => undefined : flow.removeAttachment} | |
| 811 | 1137 | imageSize={88} |
| 812 | 1138 | imageBorderRadius={20} |
| 813 | 1139 | /> |
apps/mobile/src/features/threads/NewTaskRouteScreen.tsx+112-27| ... | ... | @@ -1,9 +1,9 @@ |
| 1 | 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 | 3 | import { SymbolView } from "../../components/AppSymbol"; |
| 4 | 4 | import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; |
| 5 | import { useMemo } from "react"; | |
| 6 | import { ActivityIndicator, Platform, Pressable, ScrollView, View } from "react-native"; | |
| 5 | import { useEffect, useMemo, useRef } from "react"; | |
| 6 | import { ActivityIndicator, Alert, Platform, Pressable, ScrollView, View } from "react-native"; | |
| 7 | 7 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 8 | 8 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 9 | 9 | import { cn } from "../../lib/cn"; |
| ... | ... | @@ -16,6 +16,11 @@ import type { WorkspaceState } from "../../state/workspaceModel"; |
| 16 | 16 | import { useWorkspaceState } from "../../state/workspace"; |
| 17 | 17 | import { groupProjectsByRepository } from "../../lib/repositoryGroups"; |
| 18 | 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 | 25 | function deriveProjectEmptyState(catalogState: WorkspaceState): { |
| 21 | 26 | readonly title: string; |
| ... | ... | @@ -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 | 81 | const projects = useProjects(); |
| 77 | 82 | const threads = useThreadShells(); |
| 78 | 83 | const { state: catalogState } = useWorkspaceState(); |
| 79 | 84 | const navigation = useNavigation(); |
| 85 | const isFocused = useIsFocused(); | |
| 80 | 86 | const { layout } = useAdaptiveWorkspaceLayout(); |
| 81 | 87 | const insets = useSafeAreaInsets(); |
| 82 | 88 | const chevronColor = useThemeColor("--color-chevron"); |
| 83 | 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 | 103 | const repositoryGroups = useMemo( |
| 85 | 104 | () => groupProjectsByRepository({ projects, threads }), |
| 86 | 105 | [projects, threads], |
| ... | ... | @@ -109,6 +128,70 @@ export function NewTaskRouteScreen() { |
| 109 | 128 | return nextItems; |
| 110 | 129 | }, [repositoryGroups]); |
| 111 | 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 | 196 | return ( |
| 114 | 197 | <View collapsable={false} className="flex-1 bg-sheet"> |
| ... | ... | @@ -117,7 +200,8 @@ export function NewTaskRouteScreen() { |
| 117 | 200 | {/* Android renders its own in-screen header instead of the native bar. */} |
| 118 | 201 | <NativeStackScreenOptions options={{ headerShown: false }} /> |
| 119 | 202 | <AndroidScreenHeader |
| 120 | title="Choose project" | |
| 203 | title={screenTitle} | |
| 204 | subtitle={incomingShareSubtitle} | |
| 121 | 205 | onBack={layout.usesSplitView ? () => navigation.goBack() : undefined} |
| 122 | 206 | actions={[ |
| 123 | 207 | { |
| ... | ... | @@ -129,21 +213,29 @@ export function NewTaskRouteScreen() { |
| 129 | 213 | /> |
| 130 | 214 | </> |
| 131 | 215 | ) : ( |
| 132 | <NativeHeaderToolbar placement="right"> | |
| 133 | {layout.usesSplitView ? ( | |
| 216 | <> | |
| 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 | 232 | <NativeHeaderToolbar.Button |
| 135 | accessibilityLabel="Close new task" | |
| 136 | icon="xmark" | |
| 137 | onPress={() => navigation.goBack()} | |
| 233 | icon="plus" | |
| 234 | onPress={() => navigation.navigate("NewTaskSheet", { screen: "AddProject" })} | |
| 138 | 235 | separateBackground |
| 139 | 236 | /> |
| 140 | ) : null} | |
| 141 | <NativeHeaderToolbar.Button | |
| 142 | icon="plus" | |
| 143 | onPress={() => navigation.navigate("NewTaskSheet", { screen: "AddProject" })} | |
| 144 | separateBackground | |
| 145 | /> | |
| 146 | </NativeHeaderToolbar> | |
| 237 | </NativeHeaderToolbar> | |
| 238 | </> | |
| 147 | 239 | )} |
| 148 | 240 | |
| 149 | 241 | <ScrollView |
| ... | ... | @@ -152,6 +244,7 @@ export function NewTaskRouteScreen() { |
| 152 | 244 | className="flex-1" |
| 153 | 245 | contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }} |
| 154 | 246 | contentContainerStyle={{ |
| 247 | gap: 12, | |
| 155 | 248 | paddingHorizontal: 20, |
| 156 | 249 | paddingTop: 8, |
| 157 | 250 | }} |
| ... | ... | @@ -194,16 +287,8 @@ export function NewTaskRouteScreen() { |
| 194 | 287 | return ( |
| 195 | 288 | <Pressable |
| 196 | 289 | key={item.key} |
| 197 | onPress={() => | |
| 198 | navigation.navigate("NewTaskSheet", { | |
| 199 | screen: "NewTaskDraft", | |
| 200 | params: { | |
| 201 | environmentId: item.environmentId, | |
| 202 | projectId: item.id, | |
| 203 | title: item.title, | |
| 204 | }, | |
| 205 | }) | |
| 206 | } | |
| 290 | disabled={reservedDestinationProject !== null} | |
| 291 | onPress={() => void selectProject(item)} | |
| 207 | 292 | className={cn( |
| 208 | 293 | "bg-card px-4 py-3.5", |
| 209 | 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 | 3 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 4 | 4 | type UploadChatImageAttachment, |
| 5 | 5 | } from "@t3tools/contracts"; |
| 6 | import { estimateBase64ByteSize } from "./base64"; | |
| 6 | 7 | import { uuidv4 } from "./uuid"; |
| 7 | 8 | |
| 8 | 9 | export interface DraftComposerImageAttachment extends UploadChatImageAttachment { |
| ... | ... | @@ -25,11 +26,6 @@ export function toUploadChatImageAttachments( |
| 25 | 26 | |
| 26 | 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 | 29 | async function loadImagePicker() { |
| 34 | 30 | try { |
| 35 | 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 | 195 | if (typeName === "NativeHeaderToolbarButton") { |
| 196 | 196 | return { |
| 197 | 197 | type: "button", |
| 198 | label: "", | |
| 198 | label: typeof child.props.label === "string" ? child.props.label : "", | |
| 199 | 199 | accessibilityLabel: |
| 200 | 200 | typeof child.props.accessibilityLabel === "string" |
| 201 | 201 | ? child.props.accessibilityLabel |
| ... | ... | @@ -293,6 +293,7 @@ function NativeHeaderToolbarButton(_props: { |
| 293 | 293 | readonly accessibilityLabel?: string; |
| 294 | 294 | readonly disabled?: boolean; |
| 295 | 295 | readonly icon?: string; |
| 296 | readonly label?: string; | |
| 296 | 297 | readonly onPress?: () => void; |
| 297 | 298 | readonly separateBackground?: boolean; |
| 298 | 299 | readonly tintColor?: ColorValue; |
apps/mobile/src/state/use-composer-drafts.test.ts+89-1| ... | ... | @@ -8,7 +8,9 @@ import { |
| 8 | 8 | decodePersistedComposerDrafts, |
| 9 | 9 | type ComposerDraft, |
| 10 | 10 | getComposerDraftSnapshot, |
| 11 | mergeComposerDraftContentState, | |
| 11 | 12 | removeComposerDraftsForEnvironment, |
| 13 | restoreComposerDraftSnapshotState, | |
| 12 | 14 | } from "./use-composer-drafts"; |
| 13 | 15 | |
| 14 | 16 | const DRAFT: ComposerDraft = { |
| ... | ... | @@ -94,6 +96,7 @@ describe("mobile composer drafts", () => { |
| 94 | 96 | const draft: ComposerDraft = { |
| 95 | 97 | text: "send this", |
| 96 | 98 | attachments: [], |
| 99 | importedShareIds: ["share-1"], | |
| 97 | 100 | modelSelection: { |
| 98 | 101 | instanceId: ProviderInstanceId.make("codex"), |
| 99 | 102 | model: "gpt-5.4", |
| ... | ... | @@ -108,7 +111,8 @@ describe("mobile composer drafts", () => { |
| 108 | 111 | |
| 109 | 112 | expect(clearComposerDraftContentState({ [draftKey]: draft }, draftKey)).toEqual({ |
| 110 | 113 | [draftKey]: { |
| 111 | ...draft, | |
| 114 | modelSelection: draft.modelSelection, | |
| 115 | workspaceSelection: draft.workspaceSelection, | |
| 112 | 116 | text: "", |
| 113 | 117 | attachments: [], |
| 114 | 118 | }, |
| ... | ... | @@ -131,6 +135,90 @@ describe("mobile composer drafts", () => { |
| 131 | 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 | 222 | it("removes only drafts owned by the selected environment", () => { |
| 135 | 223 | const environmentId = EnvironmentId.make("environment-cloud"); |
| 136 | 224 | const retainedEnvironmentId = EnvironmentId.make("environment-local"); |
apps/mobile/src/state/use-composer-drafts.ts+146-2| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | 2 | import { |
| 3 | 3 | ModelSelection as ModelSelectionSchema, |
| 4 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | |
| 4 | 5 | ProviderInteractionMode as ProviderInteractionModeSchema, |
| 5 | 6 | RuntimeMode as RuntimeModeSchema, |
| 6 | 7 | type EnvironmentId, |
| ... | ... | @@ -14,6 +15,7 @@ import { Atom } from "effect/unstable/reactivity"; |
| 14 | 15 | |
| 15 | 16 | import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema"; |
| 16 | 17 | import type { DraftComposerImageAttachment } from "../lib/composerImages"; |
| 18 | import { SerializedAsyncQueue } from "../lib/serialized-async-queue"; | |
| 17 | 19 | import { appAtomRegistry } from "./atom-registry"; |
| 18 | 20 | |
| 19 | 21 | const COMPOSER_DRAFTS_SCHEMA_VERSION = 1; |
| ... | ... | @@ -38,12 +40,19 @@ export class ComposerDraftPersistenceError extends Schema.TaggedErrorClass<Compo |
| 38 | 40 | export interface ComposerDraft { |
| 39 | 41 | readonly text: string; |
| 40 | 42 | readonly attachments: ReadonlyArray<DraftComposerImageAttachment>; |
| 43 | readonly importedShareIds?: ReadonlyArray<string>; | |
| 41 | 44 | readonly modelSelection?: ModelSelection; |
| 42 | 45 | readonly runtimeMode?: RuntimeMode; |
| 43 | 46 | readonly interactionMode?: ProviderInteractionMode; |
| 44 | 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 | 56 | export interface ComposerDraftWorkspaceSelection { |
| 48 | 57 | readonly mode: "local" | "worktree"; |
| 49 | 58 | readonly branch: string | null; |
| ... | ... | @@ -66,6 +75,7 @@ const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({ |
| 66 | 75 | const ComposerDraftSchema = Schema.Struct({ |
| 67 | 76 | text: Schema.String, |
| 68 | 77 | attachments: Schema.Array(DraftComposerImageAttachmentSchema), |
| 78 | importedShareIds: Schema.optional(Schema.Array(Schema.String)), | |
| 69 | 79 | modelSelection: Schema.optional(ModelSelectionSchema), |
| 70 | 80 | runtimeMode: Schema.optional(RuntimeModeSchema), |
| 71 | 81 | interactionMode: Schema.optional(ProviderInteractionModeSchema), |
| ... | ... | @@ -93,6 +103,7 @@ export const composerDraftsAtom = Atom.make<Record<string, ComposerDraft>>({}).p |
| 93 | 103 | |
| 94 | 104 | let loadPromise: Promise<void> | null = null; |
| 95 | 105 | let persistTimer: ReturnType<typeof setTimeout> | null = null; |
| 106 | const persistenceQueue = new SerializedAsyncQueue(); | |
| 96 | 107 | |
| 97 | 108 | function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft { |
| 98 | 109 | if (!draft) { |
| ... | ... | @@ -193,7 +204,7 @@ async function writePersistedComposerDrafts(drafts: Record<string, ComposerDraft |
| 193 | 204 | |
| 194 | 205 | async function savePersistedComposerDrafts(drafts: Record<string, ComposerDraft>): Promise<void> { |
| 195 | 206 | try { |
| 196 | await writePersistedComposerDrafts(drafts); | |
| 207 | await persistenceQueue.run(() => writePersistedComposerDrafts(drafts)); | |
| 197 | 208 | } catch (error) { |
| 198 | 209 | console.warn("[composer-drafts] failed to persist drafts", error); |
| 199 | 210 | // Draft persistence is best-effort; in-memory drafts still keep working. |
| ... | ... | @@ -366,8 +377,9 @@ export function clearComposerDraftContentState( |
| 366 | 377 | if (!existing) { |
| 367 | 378 | return current; |
| 368 | 379 | } |
| 380 | const { importedShareIds: _importedShareIds, ...retained } = existing; | |
| 369 | 381 | const draft = { |
| 370 | ...existing, | |
| 382 | ...retained, | |
| 371 | 383 | text: "", |
| 372 | 384 | attachments: [], |
| 373 | 385 | }; |
| ... | ... | @@ -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 | 529 | export function clearComposerDraftContent(draftKey: string): void { |
| 386 | 530 | updateComposerDrafts((current) => clearComposerDraftContentState(current, draftKey)); |
| 387 | 531 | } |
pnpm-lock.yaml+26-29| ... | ... | @@ -61,6 +61,8 @@ overrides: |
| 61 | 61 | '@types/node': 24.12.4 |
| 62 | 62 | effect: 4.0.0-beta.78 |
| 63 | 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 | 66 | vite: npm:@voidzero-dev/vite-plus-core@0.2.2 |
| 65 | 67 | yaml: ^2.9.0 |
| 66 | 68 | |
| ... | ... | @@ -336,6 +338,9 @@ importers: |
| 336 | 338 | expo-secure-store: |
| 337 | 339 | specifier: ~56.0.4 |
| 338 | 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 | 344 | expo-splash-screen: |
| 340 | 345 | specifier: ~56.0.10 |
| 341 | 346 | version: 56.0.10(expo@56.0.12)(typescript@6.0.3) |
| ... | ... | @@ -2422,15 +2427,9 @@ packages: |
| 2422 | 2427 | '@expo/code-signing-certificates@0.0.6': |
| 2423 | 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 | 2430 | '@expo/config-plugins@56.0.9': |
| 2429 | 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 | 2433 | '@expo/config-types@56.0.6': |
| 2435 | 2434 | resolution: {integrity: sha512-4Y6Aum5J4Re5NnxGVofRNe1aDwUBOmWhQYkynZsqzRtX/zEA1ADUeyHXuEckv9YD9djiyT7bKtLt5gKL3mA6VQ==} |
| 2436 | 2435 | |
| ... | ... | @@ -6725,6 +6724,13 @@ packages: |
| 6725 | 6724 | resolution: {integrity: sha512-SmM2p2g3Jrktpiazcst+OxhjSzOHXKAY4BPURHYHXvApzzoybMmrNF4IEZ8DKZ145BhSe4ydAmlEFCRTsdtgUQ==} |
| 6726 | 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 | 6734 | expo-splash-screen@56.0.10: |
| 6729 | 6735 | resolution: {integrity: sha512-vDIlo8hzt9HlCZQ0kSY66v83D1WEXOJbVMeyPDfXDu9tbDdPMNUyDpi4WGJXikAjxnAKfbt5Mv5NnEbxINy+VA==} |
| 6730 | 6736 | peerDependencies: |
| ... | ... | @@ -12217,25 +12223,6 @@ snapshots: |
| 12217 | 12223 | dependencies: |
| 12218 | 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 | 12226 | '@expo/config-plugins@56.0.9(typescript@6.0.3)': |
| 12240 | 12227 | dependencies: |
| 12241 | 12228 | '@expo/config-types': 56.0.6 |
| ... | ... | @@ -12255,14 +12242,12 @@ snapshots: |
| 12255 | 12242 | - supports-color |
| 12256 | 12243 | - typescript |
| 12257 | 12244 | |
| 12258 | '@expo/config-types@56.0.5': {} | |
| 12259 | ||
| 12260 | 12245 | '@expo/config-types@56.0.6': {} |
| 12261 | 12246 | |
| 12262 | 12247 | '@expo/config@56.0.9(typescript@6.0.3)': |
| 12263 | 12248 | dependencies: |
| 12264 | 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 | 12251 | '@expo/json-file': 10.2.0 |
| 12267 | 12252 | '@expo/require-utils': 56.1.3(typescript@6.0.3) |
| 12268 | 12253 | deepmerge: 4.3.1 |
| ... | ... | @@ -17035,9 +17020,21 @@ snapshots: |
| 17035 | 17020 | |
| 17036 | 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 | 17035 | expo-splash-screen@56.0.10(expo@56.0.12)(typescript@6.0.3): |
| 17039 | 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 | 17038 | '@expo/image-utils': 0.10.1(typescript@6.0.3) |
| 17042 | 17039 | expo: 56.0.12(8895228379997a2a064f9644cda56ed0) |
| 17043 | 17040 | xml2js: 0.6.0 |
pnpm-workspace.yaml+2| ... | ... | @@ -86,6 +86,8 @@ overrides: |
| 86 | 86 | "@types/node": "catalog:" |
| 87 | 87 | effect: "catalog:" |
| 88 | 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 | 91 | vite: "catalog:" |
| 90 | 92 | yaml: "catalog:" |
| 91 | 93 |