1import type { SharePayload } from "expo-sharing";
2
3import { SerializedAsyncQueue } from "../../lib/serialized-async-queue";
4import {
5 hasIncomingShareContent,
6 type IncomingShareDestination,
7 type IncomingShareDraft,
8} from "./incoming-share-model";
9
10export interface IncomingShareInboxDependencies {
11 readonly loadDrafts: () => Promise<ReadonlyArray<IncomingShareDraft>>;
12 readonly writeDraft: (draft: IncomingShareDraft) => Promise<void>;
13 readonly removeDraft: (shareId: string) => Promise<void>;
14 readonly getPayloads: () => ReadonlyArray<SharePayload>;
15 readonly clearPayloads: () => void;
16 readonly buildDraft: (input: {
17 readonly payloads: ReadonlyArray<SharePayload>;
18 readonly id: string;
19 readonly createdAt: string;
20 }) => Promise<{
21 readonly draft: IncomingShareDraft;
22 readonly cleanup: () => Promise<void>;
23 readonly rollback?: () => Promise<void>;
24 }>;
25 readonly cleanupReplayedPayloads?: (payloads: ReadonlyArray<SharePayload>) => Promise<void>;
26 readonly idForPayloads: (payloads: ReadonlyArray<SharePayload>) => Promise<string>;
27 readonly now: () => string;
28 readonly onClearError?: (error: unknown) => void;
29 readonly onCleanupError?: (error: unknown) => void;
30}
31
32export function sortAndDedupeIncomingShares(
33 drafts: ReadonlyArray<IncomingShareDraft>,
34): ReadonlyArray<IncomingShareDraft> {
35 const ids = new Set<string>();
36 return [...drafts]
37 .sort((left, right) => right.createdAt.localeCompare(left.createdAt))
38 .filter((draft) => {
39 if (ids.has(draft.id)) {
40 return false;
41 }
42 ids.add(draft.id);
43 return true;
44 });
45}
46
47/**
48 * Serializes every durable inbox mutation. This prevents a stale storage load
49 * or a foreground refresh from restoring an item after it has been consumed.
50 */
51export class IncomingShareInbox {
52 private readonly operations = new SerializedAsyncQueue();
53
54 constructor(private readonly dependencies: IncomingShareInboxDependencies) {}
55
56 private runExclusive<T>(operation: () => Promise<T>): Promise<T> {
57 return this.operations.run(operation);
58 }
59
60 private clearNativePayloads(): void {
61 try {
62 this.dependencies.clearPayloads();
63 } catch (error) {
64 this.dependencies.onClearError?.(error);
65 }
66 }
67
68 private async cleanup(operation: () => Promise<void>): Promise<void> {
69 try {
70 await operation();
71 } catch (error) {
72 this.dependencies.onCleanupError?.(error);
73 }
74 }
75
76 refresh(options: { readonly ingestNative: boolean }): Promise<ReadonlyArray<IncomingShareDraft>> {
77 return this.runExclusive(async () => {
78 const loaded = await this.dependencies.loadDrafts();
79 const persisted = sortAndDedupeIncomingShares(loaded);
80 if (!options.ingestNative) {
81 return persisted;
82 }
83
84 const payloads = this.dependencies.getPayloads();
85 if (payloads.length === 0) {
86 return persisted;
87 }
88
89 // A share extension payload remains available until the containing app
90 // acknowledges it. Use a content-derived id so a crash after the durable
91 // write but before acknowledgement reuses the same inbox item.
92 const shareId = await this.dependencies.idForPayloads(payloads);
93 if (loaded.some((draft) => draft.id === shareId)) {
94 if (this.dependencies.cleanupReplayedPayloads) {
95 await this.cleanup(() => this.dependencies.cleanupReplayedPayloads!(payloads));
96 }
97 this.clearNativePayloads();
98 return persisted;
99 }
100
101 const built = await this.dependencies.buildDraft({
102 payloads,
103 id: shareId,
104 createdAt: this.dependencies.now(),
105 });
106 const { draft } = built;
107 if (!hasIncomingShareContent(draft)) {
108 // Unsupported native payloads cannot become actionable on retry and
109 // would otherwise reopen the project picker on every foreground.
110 await this.cleanup(built.cleanup);
111 this.clearNativePayloads();
112 throw new Error(
113 draft.warnings[0] ?? "The shared content is not supported by the composer.",
114 );
115 }
116
117 // The durable inbox write is the transaction boundary. Never clear the
118 // native handoff first: a process termination must leave one recoverable
119 // copy on one side of the boundary.
120 try {
121 await this.dependencies.writeDraft(draft);
122 } catch (error) {
123 if (built.rollback) {
124 await this.cleanup(built.rollback);
125 }
126 throw error;
127 }
128 await this.cleanup(built.cleanup);
129 this.clearNativePayloads();
130 return sortAndDedupeIncomingShares([draft, ...persisted]);
131 });
132 }
133
134 consume(shareId: string): Promise<ReadonlyArray<IncomingShareDraft>> {
135 return this.runExclusive(async () => {
136 // The stable payload-derived id already coalesces retries of the same
137 // native handoff. Payload equality cannot identify duplicate handoffs:
138 // users may intentionally share identical content more than once.
139 await this.dependencies.removeDraft(shareId);
140 return sortAndDedupeIncomingShares(await this.dependencies.loadDrafts());
141 });
142 }
143
144 reserve(
145 shareId: string,
146 destination: IncomingShareDestination,
147 ): Promise<ReadonlyArray<IncomingShareDraft>> {
148 return this.runExclusive(async () => {
149 const persisted = await this.dependencies.loadDrafts();
150 const target = persisted.find((draft) => draft.id === shareId);
151 if (!target) {
152 throw new Error("The shared content is no longer available.");
153 }
154 if (target.destination) {
155 if (
156 target.destination.environmentId !== destination.environmentId ||
157 target.destination.projectId !== destination.projectId
158 ) {
159 throw new Error("The shared content is already reserved for another project draft.");
160 }
161 return sortAndDedupeIncomingShares(persisted);
162 }
163
164 const reserved = { ...target, destination };
165 await this.dependencies.writeDraft(reserved);
166 return sortAndDedupeIncomingShares(
167 persisted.map((draft) => (draft.id === shareId ? reserved : draft)),
168 );
169 });
170 }
171
172 releaseReservation(
173 shareId: string,
174 expectedDestination: IncomingShareDestination,
175 ): Promise<ReadonlyArray<IncomingShareDraft>> {
176 return this.runExclusive(async () => {
177 const persisted = await this.dependencies.loadDrafts();
178 const target = persisted.find((draft) => draft.id === shareId);
179 if (!target) {
180 // Conditional release is idempotent: if another operation already
181 // consumed the share, no reservation remains to clean up.
182 return sortAndDedupeIncomingShares(persisted);
183 }
184 if (!target.destination) {
185 return sortAndDedupeIncomingShares(persisted);
186 }
187 if (
188 target.destination.environmentId !== expectedDestination.environmentId ||
189 target.destination.projectId !== expectedDestination.projectId
190 ) {
191 throw new Error("The shared content reservation changed before it could be released.");
192 }
193
194 const { destination: _destination, ...unreserved } = target;
195 await this.dependencies.writeDraft(unreserved);
196 return sortAndDedupeIncomingShares(
197 persisted.map((draft) => (draft.id === shareId ? unreserved : draft)),
198 );
199 });
200 }
201}