box-annotations 5.2.1-beta.6 → 5.2.1-beta.7

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.
@@ -0,0 +1,314 @@
1
+ import React from 'react';
2
+ import noop from 'lodash/noop';
3
+ import { useDispatch, useSelector } from 'react-redux';
4
+ import { useIntl } from 'react-intl';
5
+ import { BlueprintModernizationProvider } from '@box/blueprint-web';
6
+ import {
7
+ MentionContextProvider,
8
+ MessageEditorV2,
9
+ ThreadedAnnotationsV2,
10
+ serializeMentionMarkup,
11
+ } from '@box/threaded-annotations';
12
+ import type { DocumentNodeV2, TextMessageTypeV2 } from '@box/threaded-annotations';
13
+ import type { FetchedAvatarUrls, UserContactType } from '@box/user-selector';
14
+ import type { JSONContent } from '@tiptap/core';
15
+ import FocusTrap from 'box-ui-elements/es/components/focus-trap/FocusTrap';
16
+
17
+ import { annotationToMessages, collaboratorToUserContact } from '../../adapters/threadedAnnotationsAdapters';
18
+ import {
19
+ createReplyAction,
20
+ deleteAnnotationAction,
21
+ setActiveAnnotationIdAction,
22
+ updateAnnotationAction,
23
+ } from '../../store/annotations/actions';
24
+ import { getAnnotation } from '../../store/annotations/selectors';
25
+ import { getApiHost, getToken } from '../../store/options';
26
+ import { fetchCollaboratorsAction } from '../../store/users/actions';
27
+
28
+ import type { AppState, AppThunkDispatch } from '../../store/types';
29
+
30
+ import createPopper, { PopupReference } from './Popper';
31
+ import type { Instance, Options } from './Popper';
32
+ import messages from './messages';
33
+ import './PopupV2.scss';
34
+
35
+ export type Props = {
36
+ annotationId?: string;
37
+ onSubmit: (text: string) => void;
38
+ reference: PopupReference;
39
+ };
40
+
41
+ const getPopupOptions = (): Partial<Options> => ({
42
+ modifiers: [
43
+ { name: 'flip', options: { altAxis: false, fallbackPlacements: ['top', 'left', 'right'] } },
44
+ { name: 'offset', options: { offset: [0, 15] } },
45
+ { name: 'preventOverflow', options: { padding: 5 } },
46
+ ],
47
+ placement: 'bottom',
48
+ });
49
+
50
+ const createDocumentNode = (content: JSONContent | null): DocumentNodeV2 => {
51
+ if (!content) {
52
+ return { type: 'doc', content: [] };
53
+ }
54
+
55
+ if (content.type === 'doc' && content.content) {
56
+ return content as DocumentNodeV2;
57
+ }
58
+
59
+ return { type: 'doc', content: [content] } as DocumentNodeV2;
60
+ };
61
+
62
+ // Callers render initials as a fallback on null.
63
+ // A persistent null across all users usually indicates a stale token.
64
+ const fetchAvatarBlob = async (apiHost: string, token: string, userId: string): Promise<string | null> => {
65
+ try {
66
+ const response = await fetch(`${apiHost}/2.0/users/${userId}/avatar?pic_type=large`, {
67
+ headers: { Authorization: `Bearer ${token}` },
68
+ });
69
+ if (!response.ok) return null;
70
+ const blob = await response.blob();
71
+ return URL.createObjectURL(blob);
72
+ } catch {
73
+ return null;
74
+ }
75
+ };
76
+
77
+ const PopupV2 = ({ annotationId, onSubmit, reference }: Props): JSX.Element => {
78
+ const intl = useIntl();
79
+ const dispatch = useDispatch<AppThunkDispatch>();
80
+ const popupRef = React.useRef<HTMLDivElement>(null);
81
+ const popperRef = React.useRef<Instance>();
82
+ const optionsRef = React.useRef<Partial<Options>>(getPopupOptions());
83
+
84
+ const apiHost = useSelector(getApiHost);
85
+ const token = useSelector(getToken);
86
+ const annotation = useSelector((state: AppState) =>
87
+ annotationId ? getAnnotation(state, annotationId) : undefined,
88
+ );
89
+
90
+ const [avatarBlobs, setAvatarBlobs] = React.useState<Record<string, string>>({});
91
+ const avatarCacheRef = React.useRef<Map<string, string>>(new Map());
92
+ const credentialsRef = React.useRef({ apiHost, token });
93
+ credentialsRef.current = { apiHost, token };
94
+
95
+ const getOrFetchAvatarBlob = React.useCallback(
96
+ async (userId: string): Promise<string | null> => {
97
+ const cached = avatarCacheRef.current.get(userId);
98
+ if (cached) return cached;
99
+ const capturedApiHost = apiHost;
100
+ const capturedToken = token;
101
+ const url = await fetchAvatarBlob(capturedApiHost, capturedToken, userId);
102
+ if (!url) return null;
103
+ if (
104
+ credentialsRef.current.apiHost !== capturedApiHost ||
105
+ credentialsRef.current.token !== capturedToken
106
+ ) {
107
+ URL.revokeObjectURL(url);
108
+ return null;
109
+ }
110
+ const existing = avatarCacheRef.current.get(userId);
111
+ if (existing) {
112
+ URL.revokeObjectURL(url);
113
+ return existing;
114
+ }
115
+ avatarCacheRef.current.set(userId, url);
116
+ return url;
117
+ },
118
+ [apiHost, token],
119
+ );
120
+
121
+ React.useEffect(() => {
122
+ if (popupRef.current) {
123
+ popperRef.current = createPopper(reference, popupRef.current, optionsRef.current);
124
+ }
125
+
126
+ return () => {
127
+ popperRef.current?.destroy();
128
+ };
129
+ }, [reference]);
130
+
131
+ React.useEffect(() => {
132
+ const cache = avatarCacheRef.current;
133
+ return () => {
134
+ cache.forEach(url => URL.revokeObjectURL(url));
135
+ cache.clear();
136
+ };
137
+ }, []);
138
+
139
+ React.useEffect(() => {
140
+ avatarCacheRef.current.forEach(url => URL.revokeObjectURL(url));
141
+ avatarCacheRef.current.clear();
142
+ setAvatarBlobs({});
143
+ }, [apiHost, token]);
144
+
145
+ const handleEvent = React.useCallback((event: React.SyntheticEvent) => {
146
+ event.stopPropagation();
147
+ }, []);
148
+
149
+ React.useEffect(() => {
150
+ if (!annotation) return undefined;
151
+
152
+ const userIds = Array.from(
153
+ new Set(annotationToMessages(annotation).map(msg => String(msg.author.id))),
154
+ );
155
+ let cancelled = false;
156
+
157
+ Promise.all(userIds.map(async id => [id, await getOrFetchAvatarBlob(id)] as const)).then(entries => {
158
+ if (cancelled) return;
159
+ setAvatarBlobs(prev => {
160
+ const next = { ...prev };
161
+ entries.forEach(([id, url]) => {
162
+ if (url) next[id] = url;
163
+ });
164
+ return next;
165
+ });
166
+ });
167
+
168
+ return () => {
169
+ cancelled = true;
170
+ };
171
+ }, [annotation, getOrFetchAvatarBlob]);
172
+
173
+ const threadMessages: TextMessageTypeV2[] = React.useMemo(() => {
174
+ if (!annotation) return [];
175
+ return annotationToMessages(annotation).map(msg => ({
176
+ ...msg,
177
+ author: {
178
+ ...msg.author,
179
+ avatarUrl: avatarBlobs[String(msg.author.id)],
180
+ },
181
+ }));
182
+ }, [annotation, avatarBlobs]);
183
+
184
+ const isResolved = annotation?.status === 'resolved';
185
+ const resolvedBy = isResolved
186
+ ? annotation?.resolution?.resolved_by?.name ?? annotation?.modified_by?.name
187
+ : undefined;
188
+ const resolvedAtSource = isResolved
189
+ ? annotation?.resolution?.resolved_at ?? annotation?.modified_at
190
+ : undefined;
191
+ const resolvedAt = resolvedAtSource ? new Date(resolvedAtSource).getTime() : undefined;
192
+
193
+ const userSelectorProps = React.useMemo(
194
+ () => ({
195
+ allowEmptyQuery: true,
196
+ ariaRoleDescription: intl.formatMessage(messages.ariaLabelMentionSelector),
197
+ fetchAvatarUrls: async (userContacts: UserContactType[]): Promise<FetchedAvatarUrls> => {
198
+ const urls: FetchedAvatarUrls = {};
199
+ await Promise.all(
200
+ userContacts.map(async ({ id }) => {
201
+ const key = String(id);
202
+ const blobUrl = await getOrFetchAvatarBlob(key);
203
+ if (blobUrl) {
204
+ urls[key] = blobUrl;
205
+ }
206
+ }),
207
+ );
208
+ return urls;
209
+ },
210
+ fetchUsers: async (query: string) => {
211
+ const action = await dispatch(fetchCollaboratorsAction(query));
212
+ if (fetchCollaboratorsAction.fulfilled.match(action)) {
213
+ return action.payload.entries.map(collaboratorToUserContact);
214
+ }
215
+ return [];
216
+ },
217
+ loadingAriaLabel: intl.formatMessage(messages.ariaLabelMentionLoading),
218
+ }),
219
+ [dispatch, getOrFetchAvatarBlob, intl],
220
+ );
221
+
222
+ const handlePost = React.useCallback(
223
+ async (content: JSONContent | null): Promise<void> => {
224
+ const doc = createDocumentNode(content);
225
+ const { text } = serializeMentionMarkup(doc);
226
+ if (annotationId) {
227
+ await dispatch(createReplyAction({ annotationId, message: text }));
228
+ } else {
229
+ onSubmit(text);
230
+ }
231
+ },
232
+ [annotationId, dispatch, onSubmit],
233
+ );
234
+
235
+ const handleThreadDelete = React.useCallback(
236
+ async (): Promise<void> => {
237
+ if (annotationId) {
238
+ await dispatch(deleteAnnotationAction(annotationId));
239
+ }
240
+ },
241
+ [annotationId, dispatch],
242
+ );
243
+
244
+ const handleResolve = React.useCallback(
245
+ async (): Promise<void> => {
246
+ if (!annotationId) return;
247
+ const result = await dispatch(updateAnnotationAction({ annotationId, payload: { status: 'resolved' } }));
248
+ if (updateAnnotationAction.fulfilled.match(result)) {
249
+ dispatch(setActiveAnnotationIdAction(null));
250
+ }
251
+ },
252
+ [annotationId, dispatch],
253
+ );
254
+
255
+ const handleUnresolve = React.useCallback(
256
+ async (): Promise<void> => {
257
+ if (!annotationId) return;
258
+ await dispatch(updateAnnotationAction({ annotationId, payload: { status: 'open' } }));
259
+ },
260
+ [annotationId, dispatch],
261
+ );
262
+
263
+ return (
264
+ <FocusTrap>
265
+ <div
266
+ ref={popupRef}
267
+ aria-label={intl.formatMessage(messages.ariaLabelComment)}
268
+ className="ba-PopupV2"
269
+ data-resin-component={annotationId ? 'popupThreadV2' : 'popupReplyV2'}
270
+ onClick={handleEvent}
271
+ onMouseDown={handleEvent}
272
+ onMouseMove={handleEvent}
273
+ onMouseUp={handleEvent}
274
+ onTouchMove={handleEvent}
275
+ onTouchStart={handleEvent}
276
+ role="presentation"
277
+ >
278
+ <BlueprintModernizationProvider enableModernizedComponents>
279
+ <MentionContextProvider value={{
280
+ collaborationPopoverProps: {
281
+ onSubmit: () => Promise.resolve(),
282
+ },
283
+ }}>
284
+ {annotationId ? (
285
+ <ThreadedAnnotationsV2
286
+ isAnnotations
287
+ isResolved={isResolved}
288
+ messages={threadMessages}
289
+ onAvatarClick={noop}
290
+ onDelete={noop}
291
+ onPost={handlePost}
292
+ onResolve={handleResolve}
293
+ onThreadDelete={handleThreadDelete}
294
+ onUnresolve={handleUnresolve}
295
+ resolvedAt={resolvedAt}
296
+ resolvedBy={resolvedBy}
297
+ userSelectorProps={userSelectorProps}
298
+ />
299
+ ) : (
300
+ <MessageEditorV2
301
+ isFirstAnnotation
302
+ onPost={handlePost}
303
+ userSelectorProps={userSelectorProps}
304
+ />
305
+ )}
306
+ </MentionContextProvider>
307
+ </BlueprintModernizationProvider>
308
+ <div data-threaded-annotations-portal />
309
+ </div>
310
+ </FocusTrap>
311
+ );
312
+ };
313
+
314
+ export default PopupV2;
@@ -6,6 +6,16 @@ export default defineMessages({
6
6
  description: 'Aria label description for reply popup',
7
7
  defaultMessage: 'Comment',
8
8
  },
9
+ ariaLabelMentionLoading: {
10
+ id: 'ba.popup.reply.mentionLoading',
11
+ description: 'Aria label for mention selector loading state',
12
+ defaultMessage: 'Loading users...',
13
+ },
14
+ ariaLabelMentionSelector: {
15
+ id: 'ba.popup.reply.mentionSelector',
16
+ description: 'Aria role description for mention user selector',
17
+ defaultMessage: 'Mention a collaborator',
18
+ },
9
19
  ariaLabelReplyField: {
10
20
  id: 'ba.popup.reply.field',
11
21
  description: 'Aria label description for annotation comment field',
@@ -15,7 +15,7 @@ import { getAnnotation } from '../store/annotations';
15
15
  import { BoundingBox } from '../store/boundingBoxHighlights';
16
16
  import { getSelection } from './docUtil';
17
17
  import { Manager } from '../common/BaseManager';
18
- import { getFileId, getIsCurrentFileVersion, getViewMode, Mode } from '../store';
18
+ import { getFileId, getIsCurrentFileVersion, getRotation, getViewMode, Mode } from '../store';
19
19
  import { scrollToLocation } from '../utils/scroll';
20
20
  import './DocumentAnnotator.scss';
21
21
 
@@ -196,14 +196,35 @@ export default class DocumentAnnotator extends BaseAnnotator {
196
196
  renderPage(pageEl: HTMLElement): void {
197
197
  const pageManagers = this.getPageManagers(pageEl);
198
198
  const pageNumber = this.getPageNumber(pageEl);
199
+ const rotation = getRotation(this.store.getState()) || 0;
200
+
201
+ // Calculate original (unrotated) page dimensions for annotation layer sizing
202
+ const pageWidth = pageEl.clientWidth;
203
+ const pageHeight = pageEl.clientHeight;
204
+ const isOrthogonal = rotation % 180 !== 0;
205
+ const origWidth = isOrthogonal ? pageHeight : pageWidth;
206
+ const origHeight = isOrthogonal ? pageWidth : pageHeight;
199
207
 
200
208
  // Render annotations for every page
201
- pageManagers.forEach(manager =>
209
+ pageManagers.forEach(manager => {
210
+ // Apply rotation transform to annotation layers
211
+ if (rotation) {
212
+ manager.style({
213
+ height: `${origHeight}px`,
214
+ left: '50%',
215
+ top: '50%',
216
+ transform: `translate(-50%, -50%) rotate(${rotation}deg)`,
217
+ width: `${origWidth}px`,
218
+ });
219
+ } else {
220
+ manager.style({height: '', left: '', top: '', transform: '', width: ''});
221
+ }
222
+
202
223
  manager.render({
203
224
  intl: this.intl,
204
225
  store: this.store,
205
- }),
206
- );
226
+ });
227
+ });
207
228
 
208
229
  this.managers.set(pageNumber, pageManagers);
209
230
  }
@@ -64,6 +64,7 @@ export const DrawingTarget = (props: Props, ref: React.Ref<DrawingTargetRef>): J
64
64
  <a
65
65
  ref={ref}
66
66
  className={classNames('ba-DrawingTarget', className, { 'is-active': isActive })}
67
+ data-ba-annotation-id={annotationId}
67
68
  data-resin-itemid={annotationId}
68
69
  data-resin-target="highlightDrawing"
69
70
  data-testid={`ba-AnnotationTarget-${annotationId}`}
@@ -69,6 +69,7 @@ const HighlightTarget = (props: Props, ref: React.Ref<HighlightTargetRef>): JSX.
69
69
  <a
70
70
  ref={ref}
71
71
  className={classNames('ba-HighlightTarget', className, { 'is-active': isActive })}
72
+ data-ba-annotation-id={annotationId}
72
73
  data-ba-reference-id={uuid}
73
74
  data-resin-itemid={annotationId}
74
75
  data-resin-target="highlightText"
@@ -15,12 +15,14 @@ import {
15
15
  setMessageAction,
16
16
  getCreatorReferenceId,
17
17
  } from '../store';
18
+ import { getActiveAnnotationId } from '../store/annotations/selectors';
18
19
  import { isFeatureEnabled } from '../store/options';
19
20
  import { createDrawingAction } from '../drawing/actions';
20
21
  import { createHighlightAction } from '../highlight/actions';
21
22
  import { createRegionAction } from '../region';
22
23
 
23
24
  export type Props = {
25
+ activeAnnotationId: string | null;
24
26
  isPromoting: boolean;
25
27
  isThreadedAnnotation?: boolean;
26
28
  message: string;
@@ -32,6 +34,7 @@ export type Props = {
32
34
 
33
35
  export const mapStateToProps = (state: AppState, { location }: { location: number }): Props => {
34
36
  return {
37
+ activeAnnotationId: getActiveAnnotationId(state),
35
38
  isPromoting: getIsPromoting(state),
36
39
  isThreadedAnnotation: isFeatureEnabled(state, 'isThreadedAnnotation'),
37
40
  message: getCreatorMessage(state),
@@ -1,6 +1,7 @@
1
1
  import * as React from 'react';
2
2
  import noop from 'lodash/noop';
3
3
  import PopupReply from '../components/Popups/PopupReply';
4
+ import PopupV2 from '../components/Popups/PopupV2';
4
5
  import { CreateArg as DrawingCreateArg } from '../drawing/actions';
5
6
  import { CreateArg as HighlightCreateArg } from '../highlight/actions';
6
7
  import { CreateArg as RegionCreateArg } from '../region/actions';
@@ -13,10 +14,13 @@ import {
13
14
  Mode,
14
15
  } from '../store';
15
16
  import { PopupReference } from '../components/Popups/Popper';
16
- import './PopupLayer.scss';
17
+
17
18
  import { TARGET_TYPE } from '../constants';
18
19
 
20
+ import './PopupLayer.scss';
21
+
19
22
  export type Props = {
23
+ activeAnnotationId: string | null;
20
24
  createDrawing?: (arg: DrawingCreateArg) => void;
21
25
  createHighlight?: (arg: HighlightCreateArg) => void;
22
26
  createRegion?: (arg: RegionCreateArg) => void;
@@ -41,6 +45,7 @@ const modeStagedMap: { [M in Mode]?: (staged: CreatorItem | null) => boolean } =
41
45
 
42
46
  const PopupLayer = (props: Props): JSX.Element | null => {
43
47
  const {
48
+ activeAnnotationId,
44
49
  createDrawing = noop,
45
50
  createHighlight = noop,
46
51
  createRegion = noop,
@@ -57,6 +62,7 @@ const PopupLayer = (props: Props): JSX.Element | null => {
57
62
  } = props;
58
63
 
59
64
  const [reference, setReference] = React.useState<PopupReference | null>(null);
65
+ const [activeReference, setActiveReference] = React.useState<PopupReference | null>(null);
60
66
  const canCreate = (modeStagedMap[mode]?.(staged ?? null) ?? false) || isPromoting;
61
67
  const canReply = status !== CreatorStatus.started && status !== CreatorStatus.init;
62
68
  const isPending = status === CreatorStatus.pending;
@@ -69,17 +75,19 @@ const PopupLayer = (props: Props): JSX.Element | null => {
69
75
  setMessage(text);
70
76
  };
71
77
 
72
- const handleSubmit = (): void => {
78
+ const handleSubmit = (text?: string): void => {
73
79
  if (!staged) {
74
80
  return;
75
81
  }
76
82
 
83
+ const submitMessage = text ?? message;
84
+
77
85
  if (isCreatorStagedHighlight(staged)) {
78
- createHighlight({ ...staged, message, targetType });
86
+ createHighlight({ ...staged, message: submitMessage, targetType });
79
87
  } else if (isCreatorStagedRegion(staged)) {
80
- createRegion({ ...staged, message, targetType });
88
+ createRegion({ ...staged, message: submitMessage, targetType });
81
89
  } else if (isCreatorStagedDrawing(staged)) {
82
- createDrawing({ ...staged, message, targetType });
90
+ createDrawing({ ...staged, message: submitMessage, targetType });
83
91
  }
84
92
  };
85
93
 
@@ -87,24 +95,56 @@ const PopupLayer = (props: Props): JSX.Element | null => {
87
95
  setReference(referenceId ? document.querySelector(`[data-ba-reference-id="${referenceId}"]`) : null);
88
96
  }, [referenceId]);
89
97
 
98
+ React.useEffect(() => {
99
+ if (activeAnnotationId && isThreadedAnnotation) {
100
+ const el = document.querySelector(`[data-ba-annotation-id="${CSS.escape(activeAnnotationId)}"]`);
101
+ setActiveReference(el);
102
+ } else {
103
+ setActiveReference(null);
104
+ }
105
+ }, [activeAnnotationId, isThreadedAnnotation]);
106
+
107
+ const showCreator = canCreate && canReply && reference && staged;
108
+
109
+ if (showCreator && isThreadedAnnotation) {
110
+ return (
111
+ <div className="ba-PopupLayer-popup">
112
+ <PopupV2
113
+ onSubmit={handleSubmit}
114
+ reference={reference}
115
+ />
116
+ </div>
117
+ );
118
+ }
119
+
120
+ if (showCreator) {
121
+ return (
122
+ <div className="ba-PopupLayer-popup">
123
+ <PopupReply
124
+ isPending={isPending}
125
+ onCancel={handleCancel}
126
+ onChange={handleChange}
127
+ onSubmit={handleSubmit}
128
+ reference={reference}
129
+ value={message}
130
+ />
131
+ </div>
132
+ );
133
+ }
134
+
135
+ if (isThreadedAnnotation && activeAnnotationId && activeReference) {
136
+ return (
137
+ <div className="ba-PopupLayer-popup">
138
+ <PopupV2
139
+ annotationId={activeAnnotationId}
140
+ onSubmit={handleSubmit}
141
+ reference={activeReference}
142
+ />
143
+ </div>
144
+ );
145
+ }
90
146
 
91
- return (
92
- <>
93
- {canCreate && canReply && reference && staged && (
94
- <div className="ba-PopupLayer-popup">
95
- <PopupReply
96
- isPending={isPending}
97
- isThreadedAnnotation={isThreadedAnnotation}
98
- onCancel={handleCancel}
99
- onChange={handleChange}
100
- onSubmit={handleSubmit}
101
- reference={reference}
102
- value={message}
103
- />
104
- </div>
105
- )}
106
- </>
107
- );
147
+ return null;
108
148
  };
109
149
 
110
150
  export default PopupLayer;
@@ -36,6 +36,7 @@ export const RegionAnnotation = (props: Props, ref: React.Ref<RegionAnnotationRe
36
36
  <button
37
37
  ref={ref}
38
38
  className={classNames('ba-RegionAnnotation', className, { 'is-active': isActive })}
39
+ data-ba-annotation-id={annotationId}
39
40
  data-resin-itemid={annotationId}
40
41
  data-resin-target="highlightRegion"
41
42
  data-testid={`ba-AnnotationTarget-${annotationId}`}
@@ -1,8 +1,9 @@
1
1
  import { createAction, createAsyncThunk } from '@reduxjs/toolkit';
2
- import { Annotation, NewAnnotation } from '../../@types';
2
+ import { Annotation, NewAnnotation, Reply } from '../../@types';
3
3
  import { APICollection } from '../../api';
4
4
  import { AppThunkAPI } from '../types';
5
- import { getFileId, getFileVersionId, getPermissions } from '../options';
5
+ import { getAnnotation } from './selectors';
6
+ import { getFileId, getFileVersionId, getPermissions, isFeatureEnabled } from '../options';
6
7
 
7
8
  export const createAnnotationAction = createAsyncThunk<Annotation, NewAnnotation, AppThunkAPI>(
8
9
  'CREATE_ANNOTATION',
@@ -39,9 +40,83 @@ export const fetchAnnotationsAction = createAsyncThunk<APICollection<Annotation>
39
40
  client.destroy();
40
41
  });
41
42
 
43
+ const shouldFetchReplies = isFeatureEnabled({ options: getState().options }, 'isThreadedAnnotation');
44
+
42
45
  // Wrap the client request in a promise to allow it to be returned and cancelled
43
46
  return new Promise<APICollection<Annotation>>((resolve, reject) => {
44
- client.getAnnotations(fileId, fileVersionId, permissions, resolve, reject, 1000, false);
47
+ client.getAnnotations(fileId, fileVersionId, permissions, resolve, reject, 1000, false, shouldFetchReplies);
48
+ });
49
+ },
50
+ );
51
+
52
+ export const createReplyAction = createAsyncThunk<
53
+ { annotationId: string; reply: Reply },
54
+ { annotationId: string; message: string },
55
+ AppThunkAPI
56
+ >(
57
+ 'CREATE_REPLY',
58
+ async ({ annotationId, message }, { extra, getState, signal }) => {
59
+ const client = extra.api.getAnnotationsAPI();
60
+ const state = getState();
61
+ const fileId = getFileId(state);
62
+ const filePermissions = getPermissions(state);
63
+ const annotation = getAnnotation(state, annotationId);
64
+ const permissions = { ...filePermissions, ...annotation?.permissions };
65
+
66
+ signal.addEventListener('abort', () => {
67
+ client.destroy();
68
+ });
69
+
70
+ const reply = await new Promise<Reply>((resolve, reject) => {
71
+ client.createAnnotationReply(fileId, annotationId, permissions, message, resolve, reject);
72
+ });
73
+
74
+ return { annotationId, reply };
75
+ },
76
+ );
77
+
78
+ export const deleteAnnotationAction = createAsyncThunk<string, string, AppThunkAPI>(
79
+ 'DELETE_ANNOTATION',
80
+ async (annotationId, { extra, getState, signal }) => {
81
+ const client = extra.api.getAnnotationsAPI();
82
+ const state = getState();
83
+ const fileId = getFileId(state);
84
+ const filePermissions = getPermissions(state);
85
+ const annotation = getAnnotation(state, annotationId);
86
+ const permissions = { ...filePermissions, ...annotation?.permissions };
87
+
88
+ signal.addEventListener('abort', () => {
89
+ client.destroy();
90
+ });
91
+
92
+ await new Promise<void>((resolve, reject) => {
93
+ client.deleteAnnotation(fileId, annotationId, permissions, resolve, reject);
94
+ });
95
+
96
+ return annotationId;
97
+ },
98
+ );
99
+
100
+ export const updateAnnotationAction = createAsyncThunk<
101
+ Annotation,
102
+ { annotationId: string; payload: { message?: string; status?: string } },
103
+ AppThunkAPI
104
+ >(
105
+ 'UPDATE_ANNOTATION',
106
+ async ({ annotationId, payload }, { extra, getState, signal }) => {
107
+ const client = extra.api.getAnnotationsAPI();
108
+ const state = getState();
109
+ const fileId = getFileId(state);
110
+ const filePermissions = getPermissions(state);
111
+ const existingAnnotation = getAnnotation(state, annotationId);
112
+ const permissions = { ...filePermissions, ...existingAnnotation?.permissions };
113
+
114
+ signal.addEventListener('abort', () => {
115
+ client.destroy();
116
+ });
117
+
118
+ return new Promise<Annotation>((resolve, reject) => {
119
+ client.updateAnnotation(fileId, annotationId, permissions, payload, resolve, reject);
45
120
  });
46
121
  },
47
122
  );