Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 28 additions & 6 deletions apps/mobile/src/Stack.tsx
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
createNativeStackScreen,
type NativeStackNavigationOptions,
} from "@react-navigation/native-stack";
import { useEffect, useRef, type ReactNode } from "react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import {
Platform,
Pressable,
Expand Down Expand Up @@ -140,6 +140,7 @@ import { useAppShortcuts } from "./features/shortcuts/useAppShortcuts";
import { useIncomingShare } from "./features/sharing/IncomingShareProvider";
import {
EMPTY_INCOMING_SHARE_PRESENTATION_STATE,
incomingShareIdOfSheetRoute,
transitionIncomingSharePresentation,
} from "./features/sharing/incoming-share-presentation";
import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass";
Expand Down Expand Up @@ -587,31 +588,52 @@ function ThreadOutboxDrainWorker() {
function RootStackLayout(props: {
readonly children: React.ReactNode;
readonly state: NavigationState;
// Layout effects run before the container's focus listener is registered;
// navigator-owned helpers can dispatch on that first commit.
readonly navigation: {
readonly navigate: (name: "NewTaskSheet", params: object) => void;
};
}) {
const navigation = useNavigation();
const { pendingShare } = useIncomingShare();
const { navigation } = props;
const { pendingShare, discardShare } = useIncomingShare();
const sharePresentationRef = useRef(EMPTY_INCOMING_SHARE_PRESENTATION_STATE);
const [sharePresentationRetry, setSharePresentationRetry] = useState(0);
const path = getPathFromState(props.state, navigationPathConfig);
const pathname = path.startsWith("/") ? path : `/${path}`;
const workspaceLocation = workspaceLocationFromState(props.state);
useAgentNotificationNavigation(workspaceLocation.pathname);
useConnectOnboardingNavigation();
useAppShortcuts(props.state);
useEffect(() => {
const topRouteName = props.state.routes[props.state.index]?.name;
const sheet = incomingShareIdOfSheetRoute(props.state, "NewTaskSheet");
const transition = transitionIncomingSharePresentation(sharePresentationRef.current, {
isShareSheetPresented: topRouteName === "NewTaskSheet",
isSheetPresented: sheet.isSheetPresented,
sheetShareId: sheet.shareId,
pendingShareId: pendingShare?.id ?? null,
});
sharePresentationRef.current = transition.state;
if (transition.shareIdToDiscard) {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
const shareId = transition.shareIdToDiscard;
discardShare(shareId).catch((error: unknown) => {
console.warn("[incoming-share] could not discard dismissed share", error);
if (sharePresentationRef.current.discardedShareId === shareId) {
// A newer sheet may already be open when this discard rejects.
sharePresentationRef.current = {
...sharePresentationRef.current,
discardedShareId: null,
};
setSharePresentationRetry((attempt) => attempt + 1);
}
});
}
if (!transition.shareIdToPresent) {
return;
}
navigation.navigate("NewTaskSheet", {
screen: "NewTask",
params: { incomingShareId: transition.shareIdToPresent },
});
}, [navigation, pendingShare, props.state]);
}, [discardShare, navigation, pendingShare, props.state, sharePresentationRetry]);

return (
<HardwareKeyboardCommandProvider pathname={pathname}>
Expand Down
24 changes: 22 additions & 2 deletions apps/mobile/src/features/sharing/IncomingShareProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
import { createIncomingSharePayloadReader } from "./incoming-share-native";
import { IncomingShareInbox } from "./incoming-share-inbox";
import { persistComposerAttachmentFile } from "../../lib/composerImages";
import { scheduleUnusedComposerAttachmentCleanup } from "../../state/use-composer-drafts";
import {
loadIncomingShareDrafts,
removeIncomingShareDraft,
Expand All @@ -38,6 +39,7 @@ type IncomingShareContextValue = {
expectedDestination: IncomingShareDestination,
) => Promise<void>;
readonly consumeShare: (shareId: string) => Promise<void>;
readonly discardShare: (shareId: string) => Promise<void>;
readonly refresh: () => Promise<void>;
};

Expand Down Expand Up @@ -71,7 +73,9 @@ async function resolvedPayloadsForFiles(): Promise<ReadonlyArray<ResolvedSharePa
}
}

async function incomingShareIdForPayloads(payloads: ReadonlyArray<SharePayload>): Promise<string> {
async function incomingShareFingerprintForPayloads(
payloads: ReadonlyArray<SharePayload>,
): Promise<string> {
const fingerprint = JSON.stringify(
payloads.map((payload) => ({
shareType: payload.shareType,
Expand Down Expand Up @@ -195,7 +199,8 @@ const incomingShareInbox = new IncomingShareInbox({
};
},
cleanupReplayedPayloads: removeReplayedPayloadFiles,
idForPayloads: incomingShareIdForPayloads,
fingerprintForPayloads: incomingShareFingerprintForPayloads,
createId: () => `share-${Crypto.randomUUID()}`,
now: () => new Date().toISOString(),
onClearError: (error) => {
console.warn("[incoming-share] could not acknowledge native payload", error);
Expand Down Expand Up @@ -297,6 +302,19 @@ export function IncomingShareProvider(props: React.PropsWithChildren) {
setDrafts(snapshot);
}
}, []);
const discardShare = useCallback(
async (shareId: string) => {
const discarded = drafts.find((draft) => draft.id === shareId);
const snapshot = await incomingShareInbox.consume(shareId);
if (discarded) {
scheduleUnusedComposerAttachmentCleanup(discarded.attachments);
}
if (mountedRef.current) {
setDrafts(snapshot);
}
},
[drafts],
);
const reserveShare = useCallback(
async (shareId: string, destination: IncomingShareDestination) => {
const snapshot = await incomingShareInbox.reserve(shareId, destination);
Expand Down Expand Up @@ -329,10 +347,12 @@ export function IncomingShareProvider(props: React.PropsWithChildren) {
releaseShareReservation,
reserveShare,
consumeShare,
discardShare,
refresh,
}),
[
consumeShare,
discardShare,
drafts,
error,
getShare,
Expand Down
90 changes: 82 additions & 8 deletions apps/mobile/src/features/sharing/incoming-share-inbox.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { describe, expect, it, vi } from "@effect/vitest";
import type { SharePayload } from "expo-sharing";

import type { IncomingShareDraft } from "./incoming-share-model";
import {
EMPTY_INCOMING_SHARE_PRESENTATION_STATE,
transitionIncomingSharePresentation,
} from "./incoming-share-presentation";
import { decodeIncomingShareDraft, type IncomingShareDraft } from "./incoming-share-model";
import { IncomingShareInbox, type IncomingShareInboxDependencies } from "./incoming-share-inbox";

const PAYLOAD: SharePayload = {
Expand Down Expand Up @@ -32,8 +36,9 @@ function deferred() {
function createHarness(overrides: Partial<IncomingShareInboxDependencies> = {}) {
const persisted = new Map<string, IncomingShareDraft>();
let payloads: ReadonlyArray<SharePayload> = [PAYLOAD];
let nextId = 0;
const dependencies: IncomingShareInboxDependencies = {
loadDrafts: async () => [...persisted.values()],
loadDrafts: async () => [...persisted.values()].map(decodeIncomingShareDraft),
writeDraft: async (value) => {
persisted.set(value.id, value);
},
Expand All @@ -48,7 +53,8 @@ function createHarness(overrides: Partial<IncomingShareInboxDependencies> = {})
draft: draft(id, createdAt),
cleanup: async () => undefined,
}),
idForPayloads: async () => "share-stable",
fingerprintForPayloads: async () => "share-stable",
createId: () => `share-entry-${++nextId}`,
now: () => "2026-07-16T08:00:00.000Z",
...overrides,
};
Expand Down Expand Up @@ -76,15 +82,20 @@ describe("IncomingShareInbox", () => {
await building.promise;
return { draft: draft(id, createdAt), cleanup: async () => undefined };
});
const { inbox } = createHarness({ buildDraft });
const { inbox } = createHarness({
buildDraft,
clearPayloads: () => {
throw new Error("native acknowledgement failed");
},
});

const first = inbox.refresh({ ingestNative: true });
const second = inbox.refresh({ ingestNative: true });
building.resolve();

await expect(Promise.all([first, second])).resolves.toEqual([
[draft("share-stable")],
[draft("share-stable")],
[{ ...draft("share-entry-1"), payloadFingerprint: "share-stable" }],
[{ ...draft("share-entry-1"), payloadFingerprint: "share-stable" }],
]);
expect(buildDraft).toHaveBeenCalledTimes(1);
});
Expand All @@ -99,10 +110,12 @@ describe("IncomingShareInbox", () => {
});

const refresh = inbox.refresh({ ingestNative: true });
const consume = inbox.consume("share-stable");
const consume = inbox.consume("share-entry-1");
building.resolve();

await expect(refresh).resolves.toEqual([draft("share-stable")]);
await expect(refresh).resolves.toEqual([
{ ...draft("share-entry-1"), payloadFingerprint: "share-stable" },
]);
await expect(consume).resolves.toEqual([]);
expect([...persisted.values()]).toEqual([]);
});
Expand Down Expand Up @@ -133,6 +146,67 @@ describe("IncomingShareInbox", () => {
]);
});

it.each(["dismissed", "consumed"] as const)(
"preserves an identical new handoff after the earlier share was %s",
async (completion) => {
let payloads: ReadonlyArray<SharePayload> = [PAYLOAD];
let now = "2026-07-16T08:00:00.000Z";
const { inbox } = createHarness({
getPayloads: () => payloads,
clearPayloads: () => {
payloads = [];
},
now: () => now,
});
let pending = (await inbox.refresh({ ingestNative: true }))[0]!;
const first = pending;
let state = EMPTY_INCOMING_SHARE_PRESENTATION_STATE;
const step = (sheetShareId: string | null) => {
const result = transitionIncomingSharePresentation(state, {
pendingShareId: pending.id,
isSheetPresented: sheetShareId !== null,
sheetShareId,
});
state = result.state;
return result;
};

expect(step(null).shareIdToPresent).toBe(first.id);
expect(step(first.id).shareIdToDiscard).toBeNull();
if (completion === "dismissed") {
const dismissed = step(null);
expect(dismissed.shareIdToDiscard).toBe(first.id);
expect(dismissed.shareIdToPresent).toBeNull();
expect(step(null).shareIdToPresent).toBeNull();
await inbox.consume(dismissed.shareIdToDiscard!);
state = transitionIncomingSharePresentation(state, {
pendingShareId: null,
isSheetPresented: false,
sheetShareId: null,
}).state;
} else {
await inbox.consume(first.id);
}
await expect(inbox.refresh({ ingestNative: false })).resolves.toEqual([]);

now = "2026-07-16T08:01:00.000Z";
payloads = [PAYLOAD];
pending = (await inbox.refresh({ ingestNative: true }))[0]!;
expect(pending.text).toBe(first.text);
expect(pending.createdAt).not.toBe(first.createdAt);
if (completion === "consumed") {
// React may batch consumption and re-ingestion without rendering an empty inbox.
expect(step(first.id).shareIdToPresent).toBeNull();
}
const closed = step(null);
if (closed.shareIdToDiscard) {
await inbox.consume(closed.shareIdToDiscard);
}
await expect(inbox.refresh({ ingestNative: false })).resolves.toEqual([pending]);
expect(closed.shareIdToPresent).toBe(pending.id);
},
);

it("does not acknowledge a supported payload when its durable write fails", async () => {
const clearPayloads = vi.fn();
const cleanup = vi.fn(async () => undefined);
Expand Down
19 changes: 8 additions & 11 deletions apps/mobile/src/features/sharing/incoming-share-inbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ export interface IncomingShareInboxDependencies {
readonly rollback?: () => Promise<void>;
}>;
readonly cleanupReplayedPayloads?: (payloads: ReadonlyArray<SharePayload>) => Promise<void>;
readonly idForPayloads: (payloads: ReadonlyArray<SharePayload>) => Promise<string>;
readonly fingerprintForPayloads: (payloads: ReadonlyArray<SharePayload>) => Promise<string>;
readonly createId: () => string;
readonly now: () => string;
readonly onClearError?: (error: unknown) => void;
readonly onCleanupError?: (error: unknown) => void;
Expand Down Expand Up @@ -86,11 +87,10 @@ export class IncomingShareInbox {
return persisted;
}

// A share extension payload remains available until the containing app
// acknowledges it. Use a content-derived id so a crash after the durable
// write but before acknowledgement reuses the same inbox item.
const shareId = await this.dependencies.idForPayloads(payloads);
if (loaded.some((draft) => draft.id === shareId)) {
// Persist replay detection separately from entry identity: a later identical
// handoff must not be addressed by an earlier sheet's dismissal or import.
const payloadFingerprint = await this.dependencies.fingerprintForPayloads(payloads);
if (loaded.some((draft) => (draft.payloadFingerprint ?? draft.id) === payloadFingerprint)) {
if (this.dependencies.cleanupReplayedPayloads) {
await this.cleanup(() => this.dependencies.cleanupReplayedPayloads!(payloads));
}
Expand All @@ -100,10 +100,10 @@ export class IncomingShareInbox {

const built = await this.dependencies.buildDraft({
payloads,
id: shareId,
id: this.dependencies.createId(),
createdAt: this.dependencies.now(),
});
const { draft } = built;
const draft = { ...built.draft, payloadFingerprint };
if (!hasIncomingShareContent(draft)) {
await this.cleanup(built.cleanup);
this.clearNativePayloads();
Expand Down Expand Up @@ -131,9 +131,6 @@ export class IncomingShareInbox {

consume(shareId: string): Promise<ReadonlyArray<IncomingShareDraft>> {
return this.runExclusive(async () => {
// The stable payload-derived id already coalesces retries of the same
// native handoff. Payload equality cannot identify duplicate handoffs:
// users may intentionally share identical content more than once.
await this.dependencies.removeDraft(shareId);
return sortAndDedupeIncomingShares(await this.dependencies.loadDrafts());
});
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/features/sharing/incoming-share-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { estimateBase64ByteSize } from "../../lib/base64";
export interface IncomingShareDraft {
readonly schemaVersion: 1;
readonly id: string;
readonly payloadFingerprint?: string;
readonly createdAt: string;
readonly destination?: IncomingShareDestination;
readonly text: string;
Expand All @@ -39,6 +40,7 @@ const IncomingShareDestinationSchema = Schema.Struct({
export const IncomingShareDraftSchema = Schema.Struct({
schemaVersion: Schema.Literal(1),
id: Schema.String,
payloadFingerprint: Schema.optional(Schema.String),
createdAt: Schema.String,
destination: Schema.optional(IncomingShareDestinationSchema),
text: Schema.String,
Expand Down
Loading
Loading