@comapeo/core-react 10.0.0 → 11.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -42,7 +42,7 @@ export declare function useReceivedMapSharesState<T>(selector: (state: Array<Rec
42
42
  * @internal
43
43
  */
44
44
  export declare function useSentMapSharesActions(): {
45
- createAndSend({ projectId, receiverDeviceId, mapId, }: import("../lib/map-shares-stores.js").CreateAndSendMapShareOptions): Promise<import("@comapeo/map-server").MapShareState>;
45
+ createAndSend({ receiverDeviceId, mapId, }: import("../lib/map-shares-stores.js").CreateAndSendMapShareOptions): Promise<import("@comapeo/map-server").MapShareState>;
46
46
  cancel({ shareId }: import("../lib/map-shares-stores.js").CancelMapShareOptions): Promise<void>;
47
47
  };
48
48
  /**
@@ -123,6 +123,7 @@ export declare function useUpdateDocument<D extends WriteableDocumentType>({ doc
123
123
  projectId: string;
124
124
  }): FilteredMutationResult<UseMutationResult<Awaited<ReturnType<MapeoProjectApi[D]['update']>>, Error, {
125
125
  value: Omit<WriteableValue<D>, 'schemaName'>;
126
+ versionId: string;
126
127
  }>>;
127
128
  /**
128
129
  * Delete a document within a project.
@@ -190,7 +190,6 @@ export function useUpdateDocument({
190
190
  docType, projectId, }) {
191
191
  const queryClient = useQueryClient();
192
192
  const { data: projectApi } = useSingleProject({ projectId });
193
- // @ts-expect-error Not sure why TS complains here
194
193
  return filterMutationResult(useMutation({
195
194
  ...baseMutationOptions(),
196
195
  mutationFn: async ({ versionId, value, }) => {
@@ -156,15 +156,25 @@ export declare function useSingleReceivedMapShare({ shareId }: {
156
156
  /**
157
157
  * Accept and download a map share that has been received. The mutate promise
158
158
  * resolves once the map _starts_ downloading, before it finishes downloading.
159
- * Use `useManyMapShares` or `useSingleMapShare` to track download progress.
159
+ * Use `useManyReceivedMapShares` or `useSingleReceivedMapShare` to track
160
+ * download progress and final status.
160
161
  *
161
- * Throws if the share is not in `status="pending"` or if the download fails to
162
- * start (e.g. if the shareId if invalid).
162
+ * If the sender canceled the share before the receiver calls this, the
163
+ * mutation will still resolve (the download starts), but the share status will
164
+ * end up as `'canceled'` rather than `'completed'`. This is the only way the
165
+ * receiver discovers that a share has been canceled — check `share.status`
166
+ * after the download settles.
167
+ *
168
+ * @throws {MapShareCanceledError} If the share is already known to be canceled
169
+ * (i.e. `status` is `'canceled'` in the store, e.g. after a previous
170
+ * download attempt discovered the cancellation).
171
+ * @throws {InvalidStatusTransitionError} If the share is not in a valid state
172
+ * to start downloading (e.g. already downloading, completed, or declined).
163
173
  *
164
174
  * @example
165
175
  * ```tsx
166
176
  * function AcceptButton({ shareId }: { shareId: string }) {
167
- * const { mutate: accept } = useAcceptMapShare()
177
+ * const { mutate: accept } = useDownloadReceivedMapShare()
168
178
  *
169
179
  * return <button onClick={() => accept({ shareId })}>Accept</button>
170
180
  * }
@@ -189,17 +199,25 @@ export declare function useDownloadReceivedMapShare(): Pick<import("@tanstack/re
189
199
  }, "error" | "status" | "mutate" | "reset" | "mutateAsync">;
190
200
  /**
191
201
  * Decline a map share that has been received. Notifies the sender that the
192
- * share was declined.
202
+ * share was declined. The share status is only updated to `'declined'` after
203
+ * the server confirms the decline — there is no optimistic update.
204
+ *
205
+ * If the sender canceled the share before the decline reaches the server, the
206
+ * share status will transition to `'canceled'` (not `'error'`) and the
207
+ * mutation will throw a `MapShareCanceledError`.
193
208
  *
194
- * Throws if the share is not with `status="pending"`
195
- * Throws if shareId is invalid
196
- * Throws if decline request fails (e.g. network error)
209
+ * @throws {MapShareCanceledError} If the share is already known to be
210
+ * canceled, or if the server reports that the sender canceled the share
211
+ * while the decline was in flight. In both cases `share.status` will be
212
+ * `'canceled'`.
213
+ * @throws {InvalidStatusTransitionError} If the share is not in
214
+ * `status='pending'` (e.g. already downloading, completed, or declined).
197
215
  *
198
216
  * @example
199
217
  * ```tsx
200
218
  * import { DeclineReason } from '@comapeo/core-react'
201
219
  * function DeclineButton({ shareId }: { shareId: string }) {
202
- * const { mutate: decline } = useDeclineMapShare()
220
+ * const { mutate: decline } = useDeclineReceivedMapShare()
203
221
  *
204
222
  * return (
205
223
  * <button onClick={() => decline({ shareId, reason: DeclineReason.user_rejected })}>
@@ -229,13 +247,15 @@ export declare function useDeclineReceivedMapShare(): Pick<import("@tanstack/rea
229
247
  /**
230
248
  * Abort an in-progress map share download.
231
249
  *
232
- * Throws if the share is not in `status="downloading"`
233
- * Throws if shareId is invalid
250
+ * @throws {MapShareCanceledError} If the share is already known to be canceled.
251
+ * @throws {InvalidStatusTransitionError} If the share is not in
252
+ * `status='downloading'` (e.g. still pending, already completed, or
253
+ * declined).
234
254
  *
235
255
  * @example
236
256
  * ```tsx
237
257
  * function AbortButton({ shareId }: { shareId: string }) {
238
- * const { mutate: abort } = useAbortMapShareDownload()
258
+ * const { mutate: abort } = useAbortReceivedMapShareDownload()
239
259
  *
240
260
  * return <button onClick={() => abort({ shareId })}>Cancel Download</button>
241
261
  * }
@@ -264,8 +284,6 @@ export declare function useAbortReceivedMapShareDownload(): Pick<import("@tansta
264
284
  * mutation resolves with the created map share object, including its ID, which
265
285
  * can be used to track the share status with `useSingleSentMapShare`.
266
286
  *
267
- * @param opts.projectId Public ID of project for sending the map share: you can only send map shares to users on the same project
268
- *
269
287
  * @example
270
288
  * ```tsx
271
289
  * function SendMapButton({ projectId, deviceId }: { projectId: string; deviceId: string }) {
@@ -274,7 +292,7 @@ export declare function useAbortReceivedMapShareDownload(): Pick<import("@tansta
274
292
  * return (
275
293
  * <button
276
294
  * onClick={() =>
277
- * send({ projectId, receiverDeviceId: deviceId, mapId: 'custom' }, {
295
+ * send({ receiverDeviceId: deviceId, mapId: 'custom' }, {
278
296
  * onSuccess: (mapShare) => {
279
297
  * console.log('Share sent with id', mapShare.shareId)
280
298
  * }
@@ -168,15 +168,25 @@ export function useSingleReceivedMapShare({ shareId }) {
168
168
  /**
169
169
  * Accept and download a map share that has been received. The mutate promise
170
170
  * resolves once the map _starts_ downloading, before it finishes downloading.
171
- * Use `useManyMapShares` or `useSingleMapShare` to track download progress.
171
+ * Use `useManyReceivedMapShares` or `useSingleReceivedMapShare` to track
172
+ * download progress and final status.
172
173
  *
173
- * Throws if the share is not in `status="pending"` or if the download fails to
174
- * start (e.g. if the shareId if invalid).
174
+ * If the sender canceled the share before the receiver calls this, the
175
+ * mutation will still resolve (the download starts), but the share status will
176
+ * end up as `'canceled'` rather than `'completed'`. This is the only way the
177
+ * receiver discovers that a share has been canceled — check `share.status`
178
+ * after the download settles.
179
+ *
180
+ * @throws {MapShareCanceledError} If the share is already known to be canceled
181
+ * (i.e. `status` is `'canceled'` in the store, e.g. after a previous
182
+ * download attempt discovered the cancellation).
183
+ * @throws {InvalidStatusTransitionError} If the share is not in a valid state
184
+ * to start downloading (e.g. already downloading, completed, or declined).
175
185
  *
176
186
  * @example
177
187
  * ```tsx
178
188
  * function AcceptButton({ shareId }: { shareId: string }) {
179
- * const { mutate: accept } = useAcceptMapShare()
189
+ * const { mutate: accept } = useDownloadReceivedMapShare()
180
190
  *
181
191
  * return <button onClick={() => accept({ shareId })}>Accept</button>
182
192
  * }
@@ -193,17 +203,25 @@ export function useDownloadReceivedMapShare() {
193
203
  }
194
204
  /**
195
205
  * Decline a map share that has been received. Notifies the sender that the
196
- * share was declined.
206
+ * share was declined. The share status is only updated to `'declined'` after
207
+ * the server confirms the decline — there is no optimistic update.
208
+ *
209
+ * If the sender canceled the share before the decline reaches the server, the
210
+ * share status will transition to `'canceled'` (not `'error'`) and the
211
+ * mutation will throw a `MapShareCanceledError`.
197
212
  *
198
- * Throws if the share is not with `status="pending"`
199
- * Throws if shareId is invalid
200
- * Throws if decline request fails (e.g. network error)
213
+ * @throws {MapShareCanceledError} If the share is already known to be
214
+ * canceled, or if the server reports that the sender canceled the share
215
+ * while the decline was in flight. In both cases `share.status` will be
216
+ * `'canceled'`.
217
+ * @throws {InvalidStatusTransitionError} If the share is not in
218
+ * `status='pending'` (e.g. already downloading, completed, or declined).
201
219
  *
202
220
  * @example
203
221
  * ```tsx
204
222
  * import { DeclineReason } from '@comapeo/core-react'
205
223
  * function DeclineButton({ shareId }: { shareId: string }) {
206
- * const { mutate: decline } = useDeclineMapShare()
224
+ * const { mutate: decline } = useDeclineReceivedMapShare()
207
225
  *
208
226
  * return (
209
227
  * <button onClick={() => decline({ shareId, reason: DeclineReason.user_rejected })}>
@@ -225,13 +243,15 @@ export function useDeclineReceivedMapShare() {
225
243
  /**
226
244
  * Abort an in-progress map share download.
227
245
  *
228
- * Throws if the share is not in `status="downloading"`
229
- * Throws if shareId is invalid
246
+ * @throws {MapShareCanceledError} If the share is already known to be canceled.
247
+ * @throws {InvalidStatusTransitionError} If the share is not in
248
+ * `status='downloading'` (e.g. still pending, already completed, or
249
+ * declined).
230
250
  *
231
251
  * @example
232
252
  * ```tsx
233
253
  * function AbortButton({ shareId }: { shareId: string }) {
234
- * const { mutate: abort } = useAbortMapShareDownload()
254
+ * const { mutate: abort } = useAbortReceivedMapShareDownload()
235
255
  *
236
256
  * return <button onClick={() => abort({ shareId })}>Cancel Download</button>
237
257
  * }
@@ -255,8 +275,6 @@ export function useAbortReceivedMapShareDownload() {
255
275
  * mutation resolves with the created map share object, including its ID, which
256
276
  * can be used to track the share status with `useSingleSentMapShare`.
257
277
  *
258
- * @param opts.projectId Public ID of project for sending the map share: you can only send map shares to users on the same project
259
- *
260
278
  * @example
261
279
  * ```tsx
262
280
  * function SendMapButton({ projectId, deviceId }: { projectId: string; deviceId: string }) {
@@ -265,7 +283,7 @@ export function useAbortReceivedMapShareDownload() {
265
283
  * return (
266
284
  * <button
267
285
  * onClick={() =>
268
- * send({ projectId, receiverDeviceId: deviceId, mapId: 'custom' }, {
286
+ * send({ receiverDeviceId: deviceId, mapId: 'custom' }, {
269
287
  * onSuccess: (mapShare) => {
270
288
  * console.log('Share sent with id', mapShare.shareId)
271
289
  * }
@@ -4,7 +4,7 @@ export { useCreateDocument, useDeleteDocument, useManyDocs, usePresetsSelection,
4
4
  export { useAcceptInvite, useManyInvites, useRejectInvite, useRequestCancelInvite, useSendInvite, useSingleInvite, } from './hooks/invites.js';
5
5
  export { useMapStyleUrl, useImportCustomMapFile, useRemoveCustomMapFile, useGetCustomMapInfo, useManyReceivedMapShares, useSingleReceivedMapShare, useDeclineReceivedMapShare, useDownloadReceivedMapShare, useAbortReceivedMapShareDownload, useSendMapShare, useCancelSentMapShare, useSingleSentMapShare, } from './hooks/maps.js';
6
6
  export type { SentMapShareState, ReceivedMapShareState, AbortMapShareOptions, CancelMapShareOptions, DeclineMapShareOptions, DownloadMapShareOptions, CreateAndSendMapShareOptions, } from './lib/map-shares-stores.js';
7
- export { DeclineReason } from './lib/map-shares-stores.js';
7
+ export { DeclineReason, MapShareErrorCode, getErrorCode, MapShareCanceledError, InvalidStatusTransitionError, } from './lib/map-shares-stores.js';
8
8
  export { useAddServerPeer, useAttachmentUrl, useConnectSyncServers, useCreateBlob, useCreateProject, useDataSyncProgress, useDisconnectSyncServers, useDocumentCreatedBy, useIconUrl, useImportProjectCategories, useImportProjectConfig, useLeaveProject, useManyMembers, useManyProjects, useOwnRoleInProject, useProjectOwnRoleChangeListener, useProjectSettings, useRemoveServerPeer, useRemoveMember, useSetAutostopDataSyncTimeout, useSingleMember, useSingleProject, useStartSync, useStopSync, useSyncState, useUpdateProjectSettings, useChangeMemberRole, useExportGeoJSON, useExportZipFile, } from './hooks/projects.js';
9
9
  export type { SyncState } from './lib/sync.js';
10
10
  export type { WriteableDocument, WriteableDocumentType, WriteableValue, } from './lib/types.js';
package/dist/esm/index.js CHANGED
@@ -3,6 +3,6 @@ export { useClientApi, useIsArchiveDevice, useOwnDeviceInfo, useSetIsArchiveDevi
3
3
  export { useCreateDocument, useDeleteDocument, useManyDocs, usePresetsSelection, useSingleDocByDocId, useSingleDocByVersionId, useUpdateDocument, } from './hooks/documents.js';
4
4
  export { useAcceptInvite, useManyInvites, useRejectInvite, useRequestCancelInvite, useSendInvite, useSingleInvite, } from './hooks/invites.js';
5
5
  export { useMapStyleUrl, useImportCustomMapFile, useRemoveCustomMapFile, useGetCustomMapInfo, useManyReceivedMapShares, useSingleReceivedMapShare, useDeclineReceivedMapShare, useDownloadReceivedMapShare, useAbortReceivedMapShareDownload, useSendMapShare, useCancelSentMapShare, useSingleSentMapShare, } from './hooks/maps.js';
6
- export { DeclineReason } from './lib/map-shares-stores.js';
6
+ export { DeclineReason, MapShareErrorCode, getErrorCode, MapShareCanceledError, InvalidStatusTransitionError, } from './lib/map-shares-stores.js';
7
7
  export { useAddServerPeer, useAttachmentUrl, useConnectSyncServers, useCreateBlob, useCreateProject, useDataSyncProgress, useDisconnectSyncServers, useDocumentCreatedBy, useIconUrl, useImportProjectCategories, useImportProjectConfig, useLeaveProject, useManyMembers, useManyProjects, useOwnRoleInProject, useProjectOwnRoleChangeListener, useProjectSettings, useRemoveServerPeer, useRemoveMember, useSetAutostopDataSyncTimeout, useSingleMember, useSingleProject, useStartSync, useStopSync, useSyncState, useUpdateProjectSettings, useChangeMemberRole, useExportGeoJSON, useExportZipFile, } from './hooks/projects.js';
8
8
  export { HTTPError, isHTTPError } from './lib/http.js';
@@ -9,6 +9,107 @@ export type ReceivedMapShareState = DistributedIntersection<Simplify<MapShare>,
9
9
  export type SentMapShareState = ServerMapShareState;
10
10
  export type ReceivedMapSharesStore = ReturnType<typeof createReceivedMapSharesStore>;
11
11
  export type SentMapSharesStore = ReturnType<typeof createSentMapSharesStore>;
12
+ /**
13
+ * Error codes for map share operations. Use with {@link getErrorCode} to safely
14
+ * check the error code of an unknown error thrown by a map share mutation, or
15
+ * to check the `error.code` on a share in `status='error'`.
16
+ *
17
+ * ## Receiver errors
18
+ *
19
+ * **Mutation errors** (thrown by receiver hooks, check via
20
+ * `getErrorCode(mutation.error)`):
21
+ * - `MAP_SHARE_CANCELED` — the sender canceled the share
22
+ * - `INVALID_STATUS_TRANSITION` — the action is not valid for the share's
23
+ * current status (e.g. declining a share that is already downloading)
24
+ * - `MAP_SHARE_NOT_FOUND` — no share with the given `shareId` exists
25
+ * - `DOWNLOAD_NOT_FOUND` — abort was called but no download is tracked for
26
+ * this share
27
+ *
28
+ * **Share state errors** (on received `share.error.code` when
29
+ * `share.status === 'error'`):
30
+ * - `DOWNLOAD_ERROR` — the download failed (network, disk, or server error)
31
+ * - `DECLINE_CANNOT_CONNECT` — the decline was accepted locally but the sender
32
+ * could not be reached to notify them
33
+ *
34
+ * ## Sender errors
35
+ *
36
+ * **Mutation errors** (thrown by sender hooks, check via
37
+ * `getErrorCode(mutation.error)`):
38
+ * - `INVALID_STATUS_TRANSITION` — the action is not valid for the share's
39
+ * current status (e.g. canceling a share that is already canceled)
40
+ * - `MAP_SHARE_NOT_FOUND` — no share with the given `shareId` exists
41
+ *
42
+ * **Share state errors** (on sent `share.error.code` when
43
+ * `share.status === 'error'`):
44
+ * - `CANCEL_SHARE_NOT_CANCELABLE` — the cancel request reached the server but
45
+ * the share is already in a final state (e.g. completed or declined)
46
+ *
47
+ * ## Common
48
+ *
49
+ * - `UNKNOWN_ERROR` — fallback when the original error has no specific code.
50
+ * Can appear as both a mutation error and a share state error for either
51
+ * sender or receiver.
52
+ *
53
+ * @example
54
+ * ```tsx
55
+ * import { getErrorCode, MapShareErrorCode } from '@comapeo/core-react'
56
+ *
57
+ * // Receiver: checking a mutation error
58
+ * const decline = useDeclineReceivedMapShare()
59
+ * // ... after mutation fails:
60
+ * if (getErrorCode(decline.error) === MapShareErrorCode.MAP_SHARE_CANCELED) {
61
+ * // Show "this share was canceled by the sender"
62
+ * }
63
+ * ```
64
+ *
65
+ * @example
66
+ * ```tsx
67
+ * // Receiver: checking a share state error
68
+ * const share = useSingleReceivedMapShare({ shareId })
69
+ * if (share.status === 'error') {
70
+ * if (share.error.code === MapShareErrorCode.DOWNLOAD_ERROR) {
71
+ * // Show "download failed, try again?"
72
+ * }
73
+ * }
74
+ * ```
75
+ */
76
+ export declare const MapShareErrorCode: {
77
+ /** Receiver: the sender canceled the share before the action could complete */
78
+ readonly MAP_SHARE_CANCELED: "MAP_SHARE_CANCELED";
79
+ /** Receiver/Sender: the action is not valid for the share's current status */
80
+ readonly INVALID_STATUS_TRANSITION: "INVALID_STATUS_TRANSITION";
81
+ /** Receiver/Sender: no map share with the given `shareId` exists in the store */
82
+ readonly MAP_SHARE_NOT_FOUND: "MAP_SHARE_NOT_FOUND";
83
+ /** Receiver: abort was called but no download is tracked for this share */
84
+ readonly DOWNLOAD_NOT_FOUND: "DOWNLOAD_NOT_FOUND";
85
+ /** Receiver: the download failed due to a network, disk, or server error */
86
+ readonly DOWNLOAD_ERROR: "DOWNLOAD_ERROR";
87
+ /** Receiver: could not connect to the sender to notify them of the decline */
88
+ readonly DECLINE_CANNOT_CONNECT: "DECLINE_CANNOT_CONNECT";
89
+ /** Sender: cancel failed because the share is already in a final state on the server */
90
+ readonly CANCEL_SHARE_NOT_CANCELABLE: "CANCEL_SHARE_NOT_CANCELABLE";
91
+ /** Receiver/Sender: fallback code when the original error has no specific code */
92
+ readonly UNKNOWN_ERROR: "UNKNOWN_ERROR";
93
+ };
94
+ /**
95
+ * Safely extract the `code` property from an unknown error. Returns `undefined`
96
+ * if the value is not an Error or has no string `code` property.
97
+ *
98
+ * @example
99
+ * ```tsx
100
+ * import { getErrorCode, MapShareErrorCode } from '@comapeo/core-react'
101
+ *
102
+ * try {
103
+ * await decline.mutateAsync({ shareId, reason: 'user_rejected' })
104
+ * } catch (e) {
105
+ * const code = getErrorCode(e)
106
+ * if (code === MapShareErrorCode.MAP_SHARE_CANCELED) {
107
+ * // handle cancellation
108
+ * }
109
+ * }
110
+ * ```
111
+ */
112
+ export declare function getErrorCode(error: unknown): string | undefined;
12
113
  /** Known reasons for declining a map share */
13
114
  export declare const DeclineReason: {
14
115
  /** User explicitly rejected the map share */
@@ -35,8 +136,6 @@ export type AbortMapShareOptions = {
35
136
  };
36
137
  /** Options for creating and sending a map share */
37
138
  export type CreateAndSendMapShareOptions = {
38
- /** Public ID of the project to send the share on behalf of */
39
- projectId: string;
40
139
  /** Device ID of the recipient */
41
140
  receiverDeviceId: string;
42
141
  /** ID of the map to share - not needed until we support multiple maps */
@@ -47,6 +146,24 @@ export type CancelMapShareOptions = {
47
146
  /** ID of the map share to cancel */
48
147
  shareId: string;
49
148
  };
149
+ /**
150
+ * Thrown when a receiver action (download, decline, or abort) is attempted on a
151
+ * map share that has been canceled by the sender. Has `code: 'MAP_SHARE_CANCELED'`.
152
+ */
153
+ export declare class MapShareCanceledError extends Error {
154
+ code: "MAP_SHARE_CANCELED";
155
+ constructor(shareId: string);
156
+ }
157
+ /**
158
+ * Thrown when an action is attempted on a map share whose current status does
159
+ * not allow the requested transition (e.g. declining a share that is already
160
+ * downloading, or aborting a share that is still pending).
161
+ * Has `code: 'INVALID_STATUS_TRANSITION'`.
162
+ */
163
+ export declare class InvalidStatusTransitionError extends Error {
164
+ code: "INVALID_STATUS_TRANSITION";
165
+ constructor(current: string, next: string);
166
+ }
50
167
  /**
51
168
  * Store and actions for received map shares.
52
169
  */
@@ -73,7 +190,7 @@ export declare function createSentMapSharesStore({ clientApi, mapServerApi, }: {
73
190
  subscribe: (listener: () => void) => () => boolean;
74
191
  getSnapshot: () => ServerMapShareState[];
75
192
  actions: {
76
- createAndSend({ projectId, receiverDeviceId, mapId, }: CreateAndSendMapShareOptions): Promise<ServerMapShareState>;
193
+ createAndSend({ receiverDeviceId, mapId, }: CreateAndSendMapShareOptions): Promise<ServerMapShareState>;
77
194
  cancel({ shareId }: CancelMapShareOptions): Promise<void>;
78
195
  };
79
196
  };
@@ -8,6 +8,118 @@ import { invalidateMapQueries } from './react-query.js';
8
8
  // functions - if the documentation comments are added inline for the store
9
9
  // actions, they do not show for the mutate() function in hooks.
10
10
  // ============================================
11
+ /**
12
+ * Error codes for map share operations. Use with {@link getErrorCode} to safely
13
+ * check the error code of an unknown error thrown by a map share mutation, or
14
+ * to check the `error.code` on a share in `status='error'`.
15
+ *
16
+ * ## Receiver errors
17
+ *
18
+ * **Mutation errors** (thrown by receiver hooks, check via
19
+ * `getErrorCode(mutation.error)`):
20
+ * - `MAP_SHARE_CANCELED` — the sender canceled the share
21
+ * - `INVALID_STATUS_TRANSITION` — the action is not valid for the share's
22
+ * current status (e.g. declining a share that is already downloading)
23
+ * - `MAP_SHARE_NOT_FOUND` — no share with the given `shareId` exists
24
+ * - `DOWNLOAD_NOT_FOUND` — abort was called but no download is tracked for
25
+ * this share
26
+ *
27
+ * **Share state errors** (on received `share.error.code` when
28
+ * `share.status === 'error'`):
29
+ * - `DOWNLOAD_ERROR` — the download failed (network, disk, or server error)
30
+ * - `DECLINE_CANNOT_CONNECT` — the decline was accepted locally but the sender
31
+ * could not be reached to notify them
32
+ *
33
+ * ## Sender errors
34
+ *
35
+ * **Mutation errors** (thrown by sender hooks, check via
36
+ * `getErrorCode(mutation.error)`):
37
+ * - `INVALID_STATUS_TRANSITION` — the action is not valid for the share's
38
+ * current status (e.g. canceling a share that is already canceled)
39
+ * - `MAP_SHARE_NOT_FOUND` — no share with the given `shareId` exists
40
+ *
41
+ * **Share state errors** (on sent `share.error.code` when
42
+ * `share.status === 'error'`):
43
+ * - `CANCEL_SHARE_NOT_CANCELABLE` — the cancel request reached the server but
44
+ * the share is already in a final state (e.g. completed or declined)
45
+ *
46
+ * ## Common
47
+ *
48
+ * - `UNKNOWN_ERROR` — fallback when the original error has no specific code.
49
+ * Can appear as both a mutation error and a share state error for either
50
+ * sender or receiver.
51
+ *
52
+ * @example
53
+ * ```tsx
54
+ * import { getErrorCode, MapShareErrorCode } from '@comapeo/core-react'
55
+ *
56
+ * // Receiver: checking a mutation error
57
+ * const decline = useDeclineReceivedMapShare()
58
+ * // ... after mutation fails:
59
+ * if (getErrorCode(decline.error) === MapShareErrorCode.MAP_SHARE_CANCELED) {
60
+ * // Show "this share was canceled by the sender"
61
+ * }
62
+ * ```
63
+ *
64
+ * @example
65
+ * ```tsx
66
+ * // Receiver: checking a share state error
67
+ * const share = useSingleReceivedMapShare({ shareId })
68
+ * if (share.status === 'error') {
69
+ * if (share.error.code === MapShareErrorCode.DOWNLOAD_ERROR) {
70
+ * // Show "download failed, try again?"
71
+ * }
72
+ * }
73
+ * ```
74
+ */
75
+ export const MapShareErrorCode = {
76
+ // --- Receiver mutation errors (thrown by receiver actions) ---
77
+ /** Receiver: the sender canceled the share before the action could complete */
78
+ MAP_SHARE_CANCELED: 'MAP_SHARE_CANCELED',
79
+ /** Receiver/Sender: the action is not valid for the share's current status */
80
+ INVALID_STATUS_TRANSITION: 'INVALID_STATUS_TRANSITION',
81
+ /** Receiver/Sender: no map share with the given `shareId` exists in the store */
82
+ MAP_SHARE_NOT_FOUND: 'MAP_SHARE_NOT_FOUND',
83
+ /** Receiver: abort was called but no download is tracked for this share */
84
+ DOWNLOAD_NOT_FOUND: 'DOWNLOAD_NOT_FOUND',
85
+ // --- Receiver share state errors (in share.error.code) ---
86
+ /** Receiver: the download failed due to a network, disk, or server error */
87
+ DOWNLOAD_ERROR: 'DOWNLOAD_ERROR',
88
+ /** Receiver: could not connect to the sender to notify them of the decline */
89
+ DECLINE_CANNOT_CONNECT: 'DECLINE_CANNOT_CONNECT',
90
+ // --- Sender share state errors (in share.error.code) ---
91
+ /** Sender: cancel failed because the share is already in a final state on the server */
92
+ CANCEL_SHARE_NOT_CANCELABLE: 'CANCEL_SHARE_NOT_CANCELABLE',
93
+ // --- Common ---
94
+ /** Receiver/Sender: fallback code when the original error has no specific code */
95
+ UNKNOWN_ERROR: 'UNKNOWN_ERROR',
96
+ };
97
+ /**
98
+ * Safely extract the `code` property from an unknown error. Returns `undefined`
99
+ * if the value is not an Error or has no string `code` property.
100
+ *
101
+ * @example
102
+ * ```tsx
103
+ * import { getErrorCode, MapShareErrorCode } from '@comapeo/core-react'
104
+ *
105
+ * try {
106
+ * await decline.mutateAsync({ shareId, reason: 'user_rejected' })
107
+ * } catch (e) {
108
+ * const code = getErrorCode(e)
109
+ * if (code === MapShareErrorCode.MAP_SHARE_CANCELED) {
110
+ * // handle cancellation
111
+ * }
112
+ * }
113
+ * ```
114
+ */
115
+ export function getErrorCode(error) {
116
+ if (error instanceof Error &&
117
+ 'code' in error &&
118
+ typeof error.code === 'string') {
119
+ return error.code;
120
+ }
121
+ return undefined;
122
+ }
11
123
  /** Known reasons for declining a map share */
12
124
  export const DeclineReason = {
13
125
  /** User explicitly rejected the map share */
@@ -15,6 +127,30 @@ export const DeclineReason = {
15
127
  /** Device storage is full */
16
128
  storage_full: 'storage_full',
17
129
  };
130
+ /**
131
+ * Thrown when a receiver action (download, decline, or abort) is attempted on a
132
+ * map share that has been canceled by the sender. Has `code: 'MAP_SHARE_CANCELED'`.
133
+ */
134
+ export class MapShareCanceledError extends Error {
135
+ code = 'MAP_SHARE_CANCELED';
136
+ constructor(shareId) {
137
+ super(`Map share ${shareId} has been canceled by the sender`);
138
+ this.name = 'MapShareCanceledError';
139
+ }
140
+ }
141
+ /**
142
+ * Thrown when an action is attempted on a map share whose current status does
143
+ * not allow the requested transition (e.g. declining a share that is already
144
+ * downloading, or aborting a share that is still pending).
145
+ * Has `code: 'INVALID_STATUS_TRANSITION'`.
146
+ */
147
+ export class InvalidStatusTransitionError extends Error {
148
+ code = 'INVALID_STATUS_TRANSITION';
149
+ constructor(current, next) {
150
+ super(`Invalid status transition from ${current} to ${next}`);
151
+ this.name = 'InvalidStatusTransitionError';
152
+ }
153
+ }
18
154
  /**
19
155
  * This is like a mini zustand store. Keeping the map shares in an external
20
156
  * store avoids unnecessary re-renders of the entire app when map shares are
@@ -135,6 +271,13 @@ export function createReceivedMapSharesStore({ clientApi, mapServerApi, queryCli
135
271
  const actions = {
136
272
  async download({ shareId }) {
137
273
  const mapShare = get(shareId);
274
+ // This path should be be impossible, because the map share only receives
275
+ // status updates from the receiver after the download starts, but adding
276
+ // for completeness, and the edge-case of the receiving trying to
277
+ // download() a second time.
278
+ if (mapShare.status === 'canceled') {
279
+ throw new MapShareCanceledError(shareId);
280
+ }
138
281
  update(shareId, { status: 'downloading', bytesDownloaded: 0 });
139
282
  try {
140
283
  const downloadIdPromise = mapServerApi
@@ -176,22 +319,39 @@ export function createReceivedMapSharesStore({ clientApi, mapServerApi, queryCli
176
319
  },
177
320
  async decline({ shareId, reason }) {
178
321
  const mapShare = get(shareId);
179
- update(shareId, { status: 'declined', reason });
322
+ if (mapShare.status === 'canceled') {
323
+ throw new MapShareCanceledError(shareId);
324
+ }
325
+ if (mapShare.status !== 'pending') {
326
+ throw new InvalidStatusTransitionError(mapShare.status, 'declined');
327
+ }
180
328
  try {
181
- await mapServerApi.post(`mapShares/${shareId}/decline`, {
329
+ await mapServerApi
330
+ .post(`mapShares/${shareId}/decline`, {
182
331
  json: {
183
332
  senderDeviceId: mapShare.senderDeviceId,
184
333
  mapShareUrls: mapShare.mapShareUrls,
185
334
  reason,
186
335
  },
187
- });
336
+ })
337
+ .json();
338
+ update(shareId, { status: 'declined', reason });
188
339
  }
189
340
  catch (e) {
341
+ const error = ensureError(e);
342
+ if ('code' in error && error.code === 'MAP_SHARE_CANCELED') {
343
+ update(shareId, { status: 'canceled' });
344
+ throw new MapShareCanceledError(shareId);
345
+ }
190
346
  handleError(shareId, e);
191
347
  throw e;
192
348
  }
193
349
  },
194
350
  async abort({ shareId }) {
351
+ const mapShare = get(shareId);
352
+ if (mapShare.status === 'canceled') {
353
+ throw new MapShareCanceledError(shareId);
354
+ }
195
355
  update(shareId, { status: 'aborted' });
196
356
  try {
197
357
  const downloadId = await downloads.get(shareId);
@@ -221,15 +381,14 @@ export function createReceivedMapSharesStore({ clientApi, mapServerApi, queryCli
221
381
  export function createSentMapSharesStore({ clientApi, mapServerApi, }) {
222
382
  const { subscribe, getSnapshot, update, add, handleError, monitor } = createMapSharesStore({ mapServerApi });
223
383
  const actions = {
224
- async createAndSend({ projectId, receiverDeviceId, mapId = CUSTOM_MAP_ID, }) {
384
+ async createAndSend({ receiverDeviceId, mapId = CUSTOM_MAP_ID, }) {
225
385
  const mapShare = await mapServerApi
226
386
  .post('mapShares', {
227
387
  json: { receiverDeviceId, mapId },
228
388
  })
229
389
  .json();
230
390
  try {
231
- const project = await clientApi.getProject(projectId);
232
- await project.$sendMapShare(mapShare);
391
+ await clientApi.sendMapShare(mapShare);
233
392
  }
234
393
  catch (e) {
235
394
  await mapServerApi.post(`mapShares/${mapShare.shareId}/cancel`);
@@ -271,7 +430,7 @@ const allowedStatusTransitions = {
271
430
  */
272
431
  function assertValidStatusTransition(current, next) {
273
432
  if (!allowedStatusTransitions[current].includes(next)) {
274
- throw new Error(`Invalid status transition from ${current} to ${next}`);
433
+ throw new InvalidStatusTransitionError(current, next);
275
434
  }
276
435
  }
277
436
  const finalStatuses = [