box-annotations 5.2.1-beta.14 → 5.2.1-beta.17

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "box-annotations",
3
- "version": "5.2.1-beta.14",
3
+ "version": "5.2.1-beta.17",
4
4
  "description": "Box Annotations",
5
5
  "author": "Box (https://www.box.com/)",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -28,13 +28,13 @@
28
28
  "@babel/preset-react": "^7.25.9",
29
29
  "@babel/preset-typescript": "^7.25.9",
30
30
  "@box/frontend": "^10.0.0",
31
- "@box/blueprint-web": "^14.31.0",
32
- "@box/blueprint-web-assets": "^4.118.3",
33
- "@box/collaboration-popover": "^1.62.9",
31
+ "@box/blueprint-web": "^14.38.0",
32
+ "@box/blueprint-web-assets": "^4.120.5",
33
+ "@box/collaboration-popover": "^1.62.27",
34
34
  "@box/languages": "^1.1.0",
35
- "@box/readable-time": "^1.41.9",
36
- "@box/threaded-annotations": "^1.89.8",
37
- "@box/user-selector": "^1.76.9",
35
+ "@box/readable-time": "^1.41.27",
36
+ "@box/threaded-annotations": "^1.93.2",
37
+ "@box/user-selector": "^1.76.27",
38
38
  "@cfaester/enzyme-adapter-react-18": "^0.8.0",
39
39
  "@commitlint/cli": "^8.3.5",
40
40
  "@commitlint/config-conventional": "^8.2.0",
@@ -128,12 +128,12 @@
128
128
  "worker-farm": "^1.7.0"
129
129
  },
130
130
  "peerDependencies": {
131
- "@box/blueprint-web": "^14.31.0",
132
- "@box/blueprint-web-assets": "^4.118.3",
133
- "@box/collaboration-popover": "^1.62.9",
134
- "@box/readable-time": "^1.41.9",
135
- "@box/threaded-annotations": "^1.89.8",
136
- "@box/user-selector": "^1.76.9"
131
+ "@box/blueprint-web": "^14.38.0",
132
+ "@box/blueprint-web-assets": "^4.120.5",
133
+ "@box/collaboration-popover": "^1.62.27",
134
+ "@box/readable-time": "^1.41.27",
135
+ "@box/threaded-annotations": "^1.93.2",
136
+ "@box/user-selector": "^1.76.27"
137
137
  },
138
138
  "scripts": {
139
139
  "build": "yarn setup && yarn build:prod:dist",
package/src/@types/api.ts CHANGED
@@ -10,5 +10,6 @@ export interface Permissions {
10
10
  }
11
11
 
12
12
  export type TokenLiteral = null | undefined | string | { read: string; write?: string };
13
- export type TokenResolver = () => TokenLiteral | Promise<TokenLiteral>;
14
- export type Token = TokenLiteral | TokenResolver;
13
+ export type TokenMap = { [typedFileId: string]: TokenLiteral };
14
+ export type TokenResolver = (typedFileId?: string) => TokenLiteral | TokenMap | Promise<TokenLiteral | TokenMap>;
15
+ export type Token = TokenLiteral | TokenMap | TokenResolver;
@@ -1,19 +1,36 @@
1
1
  enum Event {
2
- ACTIVE_CHANGE = 'annotations_active_change',
2
+ // Inbound: emitted by the host; box-annotations listens.
3
3
  ACTIVE_SET = 'annotations_active_set',
4
- CREATOR_STAGED_CHANGE = 'creator_staged_change',
5
- CREATOR_STATUS_CHANGE = 'creator_status_change',
4
+ ANNOTATION_REMOVE = 'annotations_remove',
5
+ BOUNDING_BOX_HIGHLIGHTS_SET = 'bounding_box_highlights_set',
6
+ BOUNDING_BOX_HIGHLIGHT_SELECT = 'bounding_box_highlight_select',
7
+ COLOR_SET = 'annotations_color_set',
8
+ VIEW_MODE_SET = 'view_mode_set',
9
+ VISIBLE_SET = 'annotations_visible_set',
10
+
11
+ // Outbound: emitted by box-annotations; the host listens.
12
+ ACTIVE_CHANGE = 'annotations_active_change',
6
13
  ANNOTATION_CREATE = 'annotations_create',
14
+ ANNOTATION_DELETE = 'annotations_delete',
7
15
  ANNOTATION_FETCH_ERROR = 'annotations_fetch_error',
8
- ANNOTATION_REMOVE = 'annotations_remove',
16
+ ANNOTATION_REPLY_CREATE = 'annotations_reply_create',
17
+ ANNOTATION_REPLY_DELETE = 'annotations_reply_delete',
18
+ ANNOTATION_REPLY_UPDATE = 'annotations_reply_update',
19
+ ANNOTATION_UPDATE = 'annotations_update',
9
20
  ANNOTATIONS_INITIALIZED = 'annotations_initialized',
10
21
  ANNOTATIONS_MODE_CHANGE = 'annotations_mode_change',
11
- COLOR_SET = 'annotations_color_set',
12
- BOUNDING_BOX_HIGHLIGHTS_SET = 'bounding_box_highlights_set',
22
+ CREATOR_STAGED_CHANGE = 'creator_staged_change',
23
+ CREATOR_STATUS_CHANGE = 'creator_status_change',
24
+
25
+ // Internal: box-annotations emits and listens to itself (redux state change → DOM side effect).
13
26
  BOUNDING_BOX_HIGHLIGHT_NAVIGATE = 'bounding_box_highlight_navigate',
14
- BOUNDING_BOX_HIGHLIGHT_SELECT = 'bounding_box_highlight_select',
15
- VISIBLE_SET = 'annotations_visible_set',
16
- VIEW_MODE_SET = 'view_mode_set',
27
+ }
28
+
29
+ enum SidebarEvent {
30
+ SIDEBAR_ANNOTATION_UPDATE = 'sidebar.annotations_update',
31
+ SIDEBAR_REPLY_CREATE = 'sidebar.annotations_reply_create',
32
+ SIDEBAR_REPLY_DELETE = 'sidebar.annotations_reply_delete',
33
+ SIDEBAR_REPLY_UPDATE = 'sidebar.annotations_reply_update',
17
34
  }
18
35
 
19
36
  // Existing legacy events, don't rename
@@ -23,4 +40,4 @@ enum LegacyEvent {
23
40
  SCALE = 'scaleannotations',
24
41
  }
25
42
 
26
- export { Event, LegacyEvent };
43
+ export { Event, LegacyEvent, SidebarEvent };
@@ -98,6 +98,7 @@ export interface Reply {
98
98
  created_by: User;
99
99
  id: string;
100
100
  message: string;
101
+ modified_at?: string;
101
102
  parent: {
102
103
  id: string;
103
104
  type: string;
@@ -69,6 +69,20 @@ export const deserializeMentionMarkup = (text: string): DocumentNodeV2 => {
69
69
  return { type: 'doc', content };
70
70
  };
71
71
 
72
+ /**
73
+ * Returns the edit timestamp consumers use to render an edited indicator.
74
+ * Compares parsed instants, not raw strings, so equivalent ISO formats
75
+ * (Z vs +00:00, fractional precision) are treated as unedited.
76
+ */
77
+ const toUpdatedAt = (createdAt: string, modifiedAt: string | undefined): number | undefined => {
78
+ if (!modifiedAt) return undefined;
79
+ const modifiedMs = new Date(modifiedAt).getTime();
80
+ if (Number.isNaN(modifiedMs)) return undefined;
81
+ const createdMs = new Date(createdAt).getTime();
82
+ if (modifiedMs === createdMs) return undefined;
83
+ return modifiedMs;
84
+ };
85
+
72
86
  /**
73
87
  * Converts a box-annotations Reply to a threaded-annotations TextMessageType.
74
88
  */
@@ -83,10 +97,11 @@ export const replyToTextMessage = (reply: Reply): TextMessageTypeV2 => ({
83
97
  message: deserializeMentionMarkup(reply.message),
84
98
  permissions: {
85
99
  canDelete: reply.permissions?.can_delete ?? false,
86
- canEdit: false,
100
+ canEdit: reply.permissions?.can_edit ?? false,
87
101
  canReply: reply.permissions?.can_reply ?? false,
88
102
  canResolve: reply.permissions?.can_resolve ?? false,
89
103
  },
104
+ updatedAt: toUpdatedAt(reply.created_at, reply.modified_at),
90
105
  });
91
106
 
92
107
  // The root message shares the annotation's author and permissions; description
@@ -106,6 +121,7 @@ const descriptionToTextMessage = (annotation: Annotation): TextMessageTypeV2 =>
106
121
  canReply: annotation.permissions?.can_reply ?? false,
107
122
  canResolve: annotation.permissions?.can_resolve ?? false,
108
123
  },
124
+ updatedAt: toUpdatedAt(annotation.created_at, annotation.modified_at),
109
125
  });
110
126
 
111
127
  /**
@@ -1,7 +1,8 @@
1
1
  import Annotations from 'box-ui-elements/es/api/Annotations';
2
2
  import FileCollaborators from 'box-ui-elements/es/api/FileCollaborators';
3
+ import ThreadedComments from 'box-ui-elements/es/api/ThreadedComments';
3
4
  import { DEFAULT_HOSTNAME_API } from 'box-ui-elements/es/constants';
4
- import { AnnotationsAPI, CollaboratorsAPI, APIOptions } from './types';
5
+ import { AnnotationsAPI, CollaboratorsAPI, APIOptions, ThreadedCommentsAPI } from './types';
5
6
 
6
7
  export default class APIFactory {
7
8
  options: APIOptions;
@@ -21,4 +22,8 @@ export default class APIFactory {
21
22
  getCollaboratorsAPI(): CollaboratorsAPI {
22
23
  return new FileCollaborators(this.options);
23
24
  }
25
+
26
+ getThreadedCommentsAPI(): ThreadedCommentsAPI {
27
+ return new ThreadedComments(this.options);
28
+ }
24
29
  }
package/src/api/types.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Annotation, Collaborator, NewAnnotation, Permissions, Reply, Token } from '../@types';
1
+ import { Annotation, Collaborator, NewAnnotation, Permissions, Reply, ReplyPermissions, Token } from '../@types';
2
2
 
3
3
  export type APICollection<R> = {
4
4
  entries: R[];
@@ -73,6 +73,28 @@ export interface AnnotationsAPI {
73
73
  destroy(): void;
74
74
  }
75
75
 
76
+ export interface ThreadedCommentsAPI {
77
+ deleteComment(args: {
78
+ commentId: string;
79
+ errorCallback: (error: APIError) => void;
80
+ fileId: string | null;
81
+ permissions: ReplyPermissions;
82
+ successCallback: () => void;
83
+ }): void;
84
+
85
+ updateComment(args: {
86
+ commentId: string;
87
+ errorCallback: (error: APIError) => void;
88
+ fileId: string | null;
89
+ message?: string;
90
+ permissions: ReplyPermissions;
91
+ status?: string;
92
+ successCallback: (reply: Reply) => void;
93
+ }): void;
94
+
95
+ destroy(): void;
96
+ }
97
+
76
98
  export interface CollaboratorsAPI {
77
99
  getFileCollaborators(
78
100
  fileId: string | null,
@@ -34,7 +34,8 @@
34
34
  z-index: 10;
35
35
  pointer-events: none;
36
36
 
37
- .ba-Popup {
37
+ .ba-Popup,
38
+ .ba-PopupV2 {
38
39
  z-index: 150;
39
40
  pointer-events: auto;
40
41
  }
@@ -6,7 +6,7 @@ import DeselectManager from './DeselectManager';
6
6
  import EventEmitter from './EventEmitter';
7
7
  import i18n from '../utils/i18n';
8
8
  import messages from '../messages';
9
- import { Event, IntlOptions, LegacyEvent, Permissions } from '../@types';
9
+ import { Event, IntlOptions, LegacyEvent, Permissions, SidebarEvent, Token } from '../@types';
10
10
  import { BoundingBox, getBoundingBoxHighlights } from '../store/boundingBoxHighlights';
11
11
  import { ViewMode } from '../store/options/types';
12
12
  import { Features } from '../BoxAnnotations';
@@ -45,7 +45,7 @@ export type Options = {
45
45
  intl: IntlOptions;
46
46
  locale?: string;
47
47
  onCopyLink?: (params: { annotationId: string; fileVersionId: string }) => void;
48
- token: string;
48
+ token: Token;
49
49
  };
50
50
 
51
51
  export const CSS_CONTAINER_CLASS = 'ba';
@@ -131,6 +131,11 @@ export default class BaseAnnotator extends EventEmitter {
131
131
  this.addListener(Event.BOUNDING_BOX_HIGHLIGHT_SELECT, this.handleSelectBoundingBoxHighlight);
132
132
  this.addListener(Event.VIEW_MODE_SET, this.handleSetViewMode);
133
133
 
134
+ this.addListener(SidebarEvent.SIDEBAR_ANNOTATION_UPDATE, this.handleSidebarAnnotationUpdate);
135
+ this.addListener(SidebarEvent.SIDEBAR_REPLY_CREATE, this.handleSidebarReplyCreate);
136
+ this.addListener(SidebarEvent.SIDEBAR_REPLY_UPDATE, this.handleSidebarReplyUpdate);
137
+ this.addListener(SidebarEvent.SIDEBAR_REPLY_DELETE, this.handleSidebarReplyDelete);
138
+
134
139
  // Load any required data at startup
135
140
  this.hydrate();
136
141
  }
@@ -164,6 +169,10 @@ export default class BaseAnnotator extends EventEmitter {
164
169
  this.removeListener(Event.BOUNDING_BOX_HIGHLIGHT_NAVIGATE, this.handleNavigateBoundingBoxHighlight);
165
170
  this.removeListener(Event.BOUNDING_BOX_HIGHLIGHT_SELECT, this.handleSelectBoundingBoxHighlight);
166
171
  this.removeListener(Event.VIEW_MODE_SET, this.handleSetViewMode);
172
+ this.removeListener(SidebarEvent.SIDEBAR_ANNOTATION_UPDATE, this.handleSidebarAnnotationUpdate);
173
+ this.removeListener(SidebarEvent.SIDEBAR_REPLY_CREATE, this.handleSidebarReplyCreate);
174
+ this.removeListener(SidebarEvent.SIDEBAR_REPLY_UPDATE, this.handleSidebarReplyUpdate);
175
+ this.removeListener(SidebarEvent.SIDEBAR_REPLY_DELETE, this.handleSidebarReplyDelete);
167
176
  }
168
177
 
169
178
  public init(scale = 1, rotation = 0): void {
@@ -357,6 +366,22 @@ export default class BaseAnnotator extends EventEmitter {
357
366
  this.setViewMode(viewMode);
358
367
  };
359
368
 
369
+ protected handleSidebarAnnotationUpdate = (annotation: store.SidebarAnnotationUpdatePayload): void => {
370
+ this.store.dispatch(store.applySidebarAnnotationUpdateAction(annotation));
371
+ };
372
+
373
+ protected handleSidebarReplyCreate = (payload: store.SidebarReplyMutationPayload): void => {
374
+ this.store.dispatch(store.applySidebarReplyCreateAction(payload));
375
+ };
376
+
377
+ protected handleSidebarReplyUpdate = (payload: store.SidebarReplyMutationPayload): void => {
378
+ this.store.dispatch(store.applySidebarReplyUpdateAction(payload));
379
+ };
380
+
381
+ protected handleSidebarReplyDelete = ({ annotationId, id }: { annotationId: string; id: string }): void => {
382
+ this.store.dispatch(store.applySidebarReplyDeleteAction({ annotationId, replyId: id }));
383
+ };
384
+
360
385
  protected hydrate(): void {
361
386
  // Redux dispatch method signature doesn't seem to like async actions
362
387
  this.store.dispatch<any>(store.fetchAnnotationsAction()); // eslint-disable-line @typescript-eslint/no-explicit-any
@@ -21,13 +21,16 @@ import AnnotationCallbacksContext from '../../common/AnnotationCallbacksContext'
21
21
  import {
22
22
  createReplyAction,
23
23
  deleteAnnotationAction,
24
+ deleteReplyAction,
24
25
  setActiveAnnotationIdAction,
25
26
  updateAnnotationAction,
27
+ updateReplyAction,
26
28
  } from '../../store/annotations/actions';
27
29
  import { getAnnotation } from '../../store/annotations/selectors';
28
- import { getApiHost, getFileVersionId, getToken } from '../../store/options';
30
+ import { getApiHost, getFileId, getFileVersionId, getToken } from '../../store/options';
29
31
  import { fetchCollaboratorsAction } from '../../store/users/actions';
30
32
 
33
+ import type { Token, TokenLiteral, TokenMap } from '../../@types';
31
34
  import type { AppState, AppThunkDispatch } from '../../store/types';
32
35
 
33
36
  import createPopper, { PopupReference } from './Popper';
@@ -63,12 +66,32 @@ const createDocumentNode = (content: JSONContent | null): DocumentNodeV2 => {
63
66
  return { type: 'doc', content: [content] } as DocumentNodeV2;
64
67
  };
65
68
 
66
- // Callers render initials as a fallback on null.
67
- // A persistent null across all users usually indicates a stale token.
68
- const fetchAvatarBlob = async (apiHost: string, token: string, userId: string): Promise<string | null> => {
69
+ const literalToString = (literal: TokenLiteral): string | null => {
70
+ if (!literal) return null;
71
+ if (typeof literal === 'string') return literal;
72
+ return literal.read ?? literal.write ?? null;
73
+ };
74
+
75
+ const resolveStringToken = async (token: Token, typedFileId: string): Promise<string | null> => {
76
+ const resolved = typeof token === 'function' ? await token(typedFileId) : token;
77
+ if (!resolved) return null;
78
+ if (typeof resolved === 'string') return resolved;
79
+ if (typedFileId in resolved) return literalToString((resolved as TokenMap)[typedFileId]);
80
+ return literalToString(resolved as TokenLiteral);
81
+ };
82
+
83
+ const fetchAvatarBlob = async (
84
+ apiHost: string,
85
+ token: Token,
86
+ fileId: string | null,
87
+ userId: string,
88
+ ): Promise<string | null> => {
69
89
  try {
90
+ if (!fileId) return null;
91
+ const stringToken = await resolveStringToken(token, `file_${fileId}`);
92
+ if (!stringToken) return null;
70
93
  const response = await fetch(`${apiHost}/2.0/users/${userId}/avatar?pic_type=large`, {
71
- headers: { Authorization: `Bearer ${token}` },
94
+ headers: { Authorization: `Bearer ${stringToken}` },
72
95
  });
73
96
  if (!response.ok) return null;
74
97
  const blob = await response.blob();
@@ -87,6 +110,7 @@ const PopupV2 = ({ annotationId, onSubmit, popupPortalEl, reference }: Props): J
87
110
  const optionsRef = React.useRef<Partial<Options>>(getPopupOptions());
88
111
 
89
112
  const apiHost = useSelector(getApiHost);
113
+ const fileId = useSelector(getFileId);
90
114
  const fileVersionId = useSelector(getFileVersionId);
91
115
  const token = useSelector(getToken);
92
116
  const onCopyLink = React.useMemo(
@@ -111,7 +135,7 @@ const PopupV2 = ({ annotationId, onSubmit, popupPortalEl, reference }: Props): J
111
135
  if (cached) return cached;
112
136
  const capturedApiHost = apiHost;
113
137
  const capturedToken = token;
114
- const url = await fetchAvatarBlob(capturedApiHost, capturedToken, userId);
138
+ const url = await fetchAvatarBlob(capturedApiHost, capturedToken, fileId, userId);
115
139
  if (!url) return null;
116
140
  if (
117
141
  credentialsRef.current.apiHost !== capturedApiHost ||
@@ -128,7 +152,7 @@ const PopupV2 = ({ annotationId, onSubmit, popupPortalEl, reference }: Props): J
128
152
  avatarCacheRef.current.set(userId, url);
129
153
  return url;
130
154
  },
131
- [apiHost, token],
155
+ [apiHost, fileId, token],
132
156
  );
133
157
 
134
158
  React.useEffect(() => {
@@ -247,10 +271,14 @@ const PopupV2 = ({ annotationId, onSubmit, popupPortalEl, reference }: Props): J
247
271
 
248
272
  const handleEdit = React.useCallback(
249
273
  async (id: string, content: JSONContent | null): Promise<void> => {
250
- if (!annotationId || id !== annotationId) return;
274
+ if (!annotationId) return;
251
275
  const doc = createDocumentNode(content);
252
276
  const { text } = serializeMentionMarkup(doc);
253
- await dispatch(updateAnnotationAction({ annotationId, payload: { message: text } }));
277
+ if (id === annotationId) {
278
+ await dispatch(updateAnnotationAction({ annotationId, payload: { message: text } }));
279
+ return;
280
+ }
281
+ await dispatch(updateReplyAction({ annotationId, replyId: id, payload: { message: text } }));
254
282
  },
255
283
  [annotationId, dispatch],
256
284
  );
@@ -264,6 +292,14 @@ const PopupV2 = ({ annotationId, onSubmit, popupPortalEl, reference }: Props): J
264
292
  [annotationId, dispatch],
265
293
  );
266
294
 
295
+ const handleDelete = React.useCallback(
296
+ async (id: string): Promise<void> => {
297
+ if (!annotationId || id === annotationId) return;
298
+ await dispatch(deleteReplyAction({ annotationId, replyId: id }));
299
+ },
300
+ [annotationId, dispatch],
301
+ );
302
+
267
303
  const handleResolve = React.useCallback(
268
304
  async (): Promise<void> => {
269
305
  if (!annotationId) return;
@@ -314,7 +350,7 @@ const PopupV2 = ({ annotationId, onSubmit, popupPortalEl, reference }: Props): J
314
350
  messages={threadMessages}
315
351
  onAvatarClick={noop}
316
352
  onCopyLink={onCopyLink}
317
- onDelete={noop}
353
+ onDelete={handleDelete}
318
354
  onEdit={handleEdit}
319
355
  onPost={handlePost}
320
356
  onResolve={handleResolve}
@@ -45,6 +45,8 @@ const modeStagedMap: { [M in Mode]?: (staged: CreatorItem | null) => boolean } =
45
45
  [Mode.REGION]: isCreatorStagedRegion,
46
46
  };
47
47
 
48
+ const ACTIVE_TARGET_OBSERVER_TIMEOUT_MS = 10000;
49
+
48
50
  const PopupLayer = (props: Props): JSX.Element | null => {
49
51
  const {
50
52
  activeAnnotationId,
@@ -98,13 +100,39 @@ const PopupLayer = (props: Props): JSX.Element | null => {
98
100
  setReference(referenceId ? document.querySelector(`[data-ba-reference-id="${referenceId}"]`) : null);
99
101
  }, [referenceId]);
100
102
 
103
+ // activeAnnotationId can be set (e.g. via deep link) before the target DOM node mounts;
104
+ // observe until it appears, or give up after ACTIVE_TARGET_OBSERVER_TIMEOUT_MS.
101
105
  React.useEffect(() => {
102
- if (activeAnnotationId && isThreadedAnnotation) {
103
- const el = document.querySelector(`[data-ba-annotation-id="${CSS.escape(activeAnnotationId)}"]`);
104
- setActiveReference(el);
105
- } else {
106
+ if (!activeAnnotationId || !isThreadedAnnotation) {
106
107
  setActiveReference(null);
108
+ return noop;
109
+ }
110
+
111
+ const selector = `[data-ba-annotation-id="${CSS.escape(activeAnnotationId)}"]`;
112
+ const existing = document.querySelector(selector);
113
+ if (existing) {
114
+ setActiveReference(existing);
115
+ return noop;
107
116
  }
117
+
118
+ setActiveReference(null);
119
+ const observer = new MutationObserver(() => {
120
+ const el = document.querySelector(selector);
121
+ if (el) {
122
+ setActiveReference(el);
123
+ observer.disconnect();
124
+ }
125
+ });
126
+ observer.observe(document.body, { childList: true, subtree: true });
127
+
128
+ const timeoutId = window.setTimeout(() => {
129
+ observer.disconnect();
130
+ }, ACTIVE_TARGET_OBSERVER_TIMEOUT_MS);
131
+
132
+ return () => {
133
+ observer.disconnect();
134
+ window.clearTimeout(timeoutId);
135
+ };
108
136
  }, [activeAnnotationId, isThreadedAnnotation]);
109
137
 
110
138
  const showCreator = canCreate && canReply && reference && staged;
@@ -121,6 +121,95 @@ export const updateAnnotationAction = createAsyncThunk<
121
121
  },
122
122
  );
123
123
 
124
+ export const updateReplyAction = createAsyncThunk<
125
+ { annotationId: string; reply: Reply },
126
+ { annotationId: string; replyId: string; payload: { message?: string; status?: string } },
127
+ AppThunkAPI
128
+ >(
129
+ 'UPDATE_REPLY',
130
+ async ({ annotationId, replyId, payload }, { extra, getState, signal }) => {
131
+ const client = extra.api.getThreadedCommentsAPI();
132
+ const state = getState();
133
+ const fileId = getFileId(state);
134
+ const annotation = getAnnotation(state, annotationId);
135
+ const reply = annotation?.replies?.find(r => r.id === replyId);
136
+
137
+ if (!reply) {
138
+ throw new Error(`updateReplyAction: reply ${replyId} not found on annotation ${annotationId}`);
139
+ }
140
+
141
+ signal.addEventListener('abort', () => {
142
+ client.destroy();
143
+ });
144
+
145
+ const updated = await new Promise<Reply>((resolve, reject) => {
146
+ client.updateComment({
147
+ commentId: replyId,
148
+ errorCallback: reject,
149
+ fileId,
150
+ message: payload.message,
151
+ permissions: reply.permissions ?? {},
152
+ status: payload.status,
153
+ successCallback: resolve,
154
+ });
155
+ });
156
+
157
+ return { annotationId, reply: updated };
158
+ },
159
+ );
160
+
161
+ export const deleteReplyAction = createAsyncThunk<
162
+ { annotationId: string; replyId: string },
163
+ { annotationId: string; replyId: string },
164
+ AppThunkAPI
165
+ >(
166
+ 'DELETE_REPLY',
167
+ async ({ annotationId, replyId }, { extra, getState, signal }) => {
168
+ const client = extra.api.getThreadedCommentsAPI();
169
+ const state = getState();
170
+ const fileId = getFileId(state);
171
+ const annotation = getAnnotation(state, annotationId);
172
+ const reply = annotation?.replies?.find(r => r.id === replyId);
173
+
174
+ if (!reply) {
175
+ throw new Error(`deleteReplyAction: reply ${replyId} not found on annotation ${annotationId}`);
176
+ }
177
+
178
+ signal.addEventListener('abort', () => {
179
+ client.destroy();
180
+ });
181
+
182
+ await new Promise<void>((resolve, reject) => {
183
+ client.deleteComment({
184
+ commentId: replyId,
185
+ errorCallback: reject,
186
+ fileId,
187
+ permissions: reply.permissions ?? {},
188
+ successCallback: resolve,
189
+ });
190
+ });
191
+
192
+ return { annotationId, replyId };
193
+ },
194
+ );
195
+
196
+ export type SidebarAnnotationUpdatePayload = Partial<Annotation> & { id: string };
197
+ export type SidebarReplyMutationPayload = { annotationId: string; reply: Reply };
198
+ export type SidebarReplyDeletePayload = { annotationId: string; replyId: string };
199
+
200
+ export const applySidebarAnnotationUpdateAction = createAction<SidebarAnnotationUpdatePayload>(
201
+ 'APPLY_SIDEBAR_ANNOTATION_UPDATE',
202
+ );
203
+ export const applySidebarReplyCreateAction = createAction<SidebarReplyMutationPayload>(
204
+ 'APPLY_SIDEBAR_REPLY_CREATE',
205
+ );
206
+ export const applySidebarReplyDeleteAction = createAction<SidebarReplyDeletePayload>(
207
+ 'APPLY_SIDEBAR_REPLY_DELETE',
208
+ );
209
+ export const applySidebarReplyUpdateAction = createAction<SidebarReplyMutationPayload>(
210
+ 'APPLY_SIDEBAR_REPLY_UPDATE',
211
+ );
212
+
124
213
  export const removeAnnotationAction = createAction<string>('REMOVE_ANNOTATION');
125
214
  export const setActiveAnnotationIdAction = createAction<string | null>('SET_ACTIVE_ANNOTATION_ID');
126
215
  export const setIsInitialized = createAction('SET_IS_INITIALIZED');
@@ -2,14 +2,20 @@ import { createReducer, combineReducers } from '@reduxjs/toolkit';
2
2
  import { formatDrawing, isDrawing } from '../../drawing/drawingUtil';
3
3
  import { AnnotationsState } from './types';
4
4
  import {
5
+ applySidebarAnnotationUpdateAction,
6
+ applySidebarReplyCreateAction,
7
+ applySidebarReplyDeleteAction,
8
+ applySidebarReplyUpdateAction,
5
9
  createAnnotationAction,
6
10
  createReplyAction,
7
11
  deleteAnnotationAction,
12
+ deleteReplyAction,
8
13
  fetchAnnotationsAction,
9
14
  removeAnnotationAction,
10
15
  setActiveAnnotationIdAction,
11
16
  setIsInitialized,
12
17
  updateAnnotationAction,
18
+ updateReplyAction,
13
19
  } from './actions';
14
20
  import { setViewModeAction } from '../options/actions';
15
21
 
@@ -58,6 +64,48 @@ const annotationsById = createReducer<AnnotationsState['byId']>({}, builder =>
58
64
  .addCase(updateAnnotationAction.fulfilled, (state, { payload }) => {
59
65
  state[payload.id] = isDrawing(payload) ? formatDrawing(payload) : payload;
60
66
  })
67
+ .addCase(updateReplyAction.fulfilled, (state, { payload: { annotationId, reply } }) => {
68
+ const annotation = state[annotationId];
69
+ if (annotation && annotation.replies) {
70
+ annotation.replies = annotation.replies.map(existing =>
71
+ existing.id === reply.id ? reply : existing,
72
+ );
73
+ }
74
+ })
75
+ .addCase(deleteReplyAction.fulfilled, (state, { payload: { annotationId, replyId } }) => {
76
+ const annotation = state[annotationId];
77
+ if (annotation && annotation.replies) {
78
+ annotation.replies = annotation.replies.filter(existing => existing.id !== replyId);
79
+ }
80
+ })
81
+ .addCase(applySidebarAnnotationUpdateAction, (state, { payload }) => {
82
+ const existing = state[payload.id];
83
+ if (!existing) return;
84
+ Object.entries(payload).forEach(([key, value]) => {
85
+ if (value !== undefined) {
86
+ (existing as Record<string, unknown>)[key] = value;
87
+ }
88
+ });
89
+ })
90
+ .addCase(applySidebarReplyCreateAction, (state, { payload: { annotationId, reply } }) => {
91
+ const annotation = state[annotationId];
92
+ if (!annotation) return;
93
+ const replies = annotation.replies ?? [];
94
+ if (replies.some(existing => existing.id === reply.id)) return;
95
+ annotation.replies = [...replies, reply];
96
+ })
97
+ .addCase(applySidebarReplyUpdateAction, (state, { payload: { annotationId, reply } }) => {
98
+ const annotation = state[annotationId];
99
+ if (!annotation || !annotation.replies) return;
100
+ annotation.replies = annotation.replies.map(existing =>
101
+ existing.id === reply.id ? { ...existing, ...reply } : existing,
102
+ );
103
+ })
104
+ .addCase(applySidebarReplyDeleteAction, (state, { payload: { annotationId, replyId } }) => {
105
+ const annotation = state[annotationId];
106
+ if (!annotation || !annotation.replies) return;
107
+ annotation.replies = annotation.replies.filter(existing => existing.id !== replyId);
108
+ })
61
109
  .addCase(fetchAnnotationsAction.fulfilled, (state, { payload }) => {
62
110
  payload.entries.forEach(annotation => {
63
111
  state[annotation.id] = isDrawing(annotation) ? formatDrawing(annotation) : annotation;
@@ -1,9 +1,9 @@
1
1
  import eventManager from '../../common/EventManager';
2
2
  import { AsyncAction, Status } from './types';
3
3
  import { AppState } from '../types';
4
- import { Event } from '../../@types';
4
+ import { Annotation, Event, NewAnnotation } from '../../@types';
5
5
 
6
- const emitCreateEvent = (action: AsyncAction, status: Status): void => {
6
+ const emitCreateEvent = (action: AsyncAction<NewAnnotation, Annotation>, status: Status): void => {
7
7
  const { error, meta: { arg, requestId } = {}, payload } = action;
8
8
  eventManager.emit(Event.ANNOTATION_CREATE, {
9
9
  annotation: payload || arg,
@@ -16,7 +16,7 @@ const emitCreateEvent = (action: AsyncAction, status: Status): void => {
16
16
  };
17
17
 
18
18
  const createHandler = (status: Status) => (prevState: AppState, nextState: AppState, action: AsyncAction): void =>
19
- emitCreateEvent(action, status);
19
+ emitCreateEvent(action as AsyncAction<NewAnnotation, Annotation>, status);
20
20
 
21
21
  const handleCreateErrorEvents = createHandler(Status.ERROR);
22
22
  const handleCreatePendingEvents = createHandler(Status.PENDING);