box-annotations 5.2.1-beta.2 → 5.2.1-beta.21

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.
Files changed (91) hide show
  1. package/dist/annotations.css +3 -4
  2. package/dist/annotations.js +1 -1
  3. package/dist/annotations.js.LICENSE.txt +3 -43
  4. package/i18n/en-US.js +4 -0
  5. package/i18n/en-US.properties +8 -0
  6. package/i18n/json/src/components/BoundingBoxHighlight/messages.json +1 -0
  7. package/i18n/json/src/components/Popups/messages.json +1 -1
  8. package/index.js +1 -1
  9. package/package.json +33 -15
  10. package/src/@types/api.ts +3 -2
  11. package/src/@types/events.ts +28 -7
  12. package/src/@types/global.ts +0 -9
  13. package/src/@types/model.ts +14 -0
  14. package/src/BoxAnnotations.ts +4 -1
  15. package/src/adapters/threadedAnnotationsAdapters.ts +164 -0
  16. package/src/api/APIFactory.ts +6 -1
  17. package/src/api/types.ts +50 -1
  18. package/src/boundingBoxHighlight/BoundingBoxHighlightContainer.tsx +33 -0
  19. package/src/boundingBoxHighlight/BoundingBoxHighlightManager.tsx +19 -0
  20. package/src/boundingBoxHighlight/index.ts +1 -0
  21. package/src/common/AnnotationCallbacksContext.ts +7 -0
  22. package/src/common/BaseAnnotator.scss +14 -1
  23. package/src/common/BaseAnnotator.ts +161 -14
  24. package/src/common/BaseManager.ts +2 -0
  25. package/src/common/DeselectListener.tsx +3 -1
  26. package/src/common/withProviders.tsx +6 -2
  27. package/src/components/BoundingBoxHighlight/BoundingBoxHighlightList.scss +8 -0
  28. package/src/components/BoundingBoxHighlight/BoundingBoxHighlightList.tsx +67 -0
  29. package/src/components/BoundingBoxHighlight/BoundingBoxHighlightNav.scss +73 -0
  30. package/src/components/BoundingBoxHighlight/BoundingBoxHighlightNav.tsx +68 -0
  31. package/src/components/BoundingBoxHighlight/BoundingBoxHighlightRect.scss +22 -0
  32. package/src/components/BoundingBoxHighlight/BoundingBoxHighlightRect.tsx +77 -0
  33. package/src/components/BoundingBoxHighlight/index.ts +4 -0
  34. package/src/components/BoundingBoxHighlight/messages.ts +14 -0
  35. package/src/components/Popups/PopupDrawingToolbar.tsx +0 -7
  36. package/src/components/Popups/PopupReply.tsx +8 -28
  37. package/src/components/Popups/PopupV2.scss +28 -0
  38. package/src/components/Popups/PopupV2.tsx +382 -0
  39. package/src/components/Popups/messages.ts +10 -0
  40. package/src/document/DocumentAnnotator.ts +79 -10
  41. package/src/document/docUtil.ts +2 -1
  42. package/src/drawing/DrawingAnnotations.tsx +23 -14
  43. package/src/drawing/DrawingAnnotationsContainer.tsx +3 -0
  44. package/src/drawing/DrawingCreator.tsx +9 -16
  45. package/src/drawing/DrawingManager.tsx +13 -2
  46. package/src/drawing/DrawingTarget.tsx +1 -0
  47. package/src/highlight/HighlightAnnotations.tsx +27 -14
  48. package/src/highlight/HighlightContainer.tsx +3 -0
  49. package/src/highlight/HighlightCreatorManager.ts +8 -5
  50. package/src/highlight/HighlightListener.ts +4 -2
  51. package/src/highlight/HighlightManager.tsx +13 -2
  52. package/src/highlight/HighlightTarget.tsx +1 -0
  53. package/src/icons/ChevronLeft.tsx +24 -0
  54. package/src/icons/ChevronRight.tsx +24 -0
  55. package/src/image/ImageAnnotator.ts +50 -3
  56. package/src/media/MediaAnnotator.ts +2 -1
  57. package/src/popup/PopupContainer.tsx +11 -1
  58. package/src/popup/PopupLayer.tsx +97 -22
  59. package/src/popup/PopupManager.tsx +24 -4
  60. package/src/region/RegionAnnotation.tsx +1 -0
  61. package/src/region/RegionCreation.tsx +11 -8
  62. package/src/region/RegionCreationContainer.tsx +2 -2
  63. package/src/region/RegionCreator.tsx +9 -17
  64. package/src/store/annotations/actions.ts +167 -3
  65. package/src/store/annotations/reducer.ts +71 -1
  66. package/src/store/boundingBoxHighlights/actions.ts +6 -0
  67. package/src/store/boundingBoxHighlights/index.ts +4 -0
  68. package/src/store/boundingBoxHighlights/reducer.ts +33 -0
  69. package/src/store/boundingBoxHighlights/selectors.ts +12 -0
  70. package/src/store/boundingBoxHighlights/types.ts +13 -0
  71. package/src/store/createRootReducer.ts +2 -0
  72. package/src/store/eventing/boundingBoxHighlightNav.ts +13 -0
  73. package/src/store/eventing/create.ts +3 -3
  74. package/src/store/eventing/delete.ts +27 -0
  75. package/src/store/eventing/middleware.ts +42 -3
  76. package/src/store/eventing/replyCreate.ts +34 -0
  77. package/src/store/eventing/replyDelete.ts +35 -0
  78. package/src/store/eventing/replyUpdate.ts +39 -0
  79. package/src/store/eventing/types.ts +4 -4
  80. package/src/store/eventing/update.ts +29 -0
  81. package/src/store/highlight/actions.ts +32 -3
  82. package/src/store/highlight/types.ts +1 -0
  83. package/src/store/index.ts +1 -0
  84. package/src/store/options/actions.ts +2 -0
  85. package/src/store/options/reducer.ts +9 -2
  86. package/src/store/options/selectors.ts +5 -1
  87. package/src/store/options/types.ts +7 -1
  88. package/src/store/types.ts +2 -0
  89. package/src/utils/rotate.ts +43 -0
  90. package/src/utils/scroll.ts +8 -3
  91. package/src/components/Popups/PopupReplyV2.tsx +0 -10
@@ -0,0 +1,77 @@
1
+ import * as React from 'react';
2
+ import classNames from 'classnames';
3
+ import BoundingBoxHighlightNav from './BoundingBoxHighlightNav';
4
+ import { BoundingBox } from '../../store/boundingBoxHighlights/types';
5
+ import './BoundingBoxHighlightRect.scss';
6
+
7
+ type Props = {
8
+ boundingBox: BoundingBox;
9
+ currentIndex: number;
10
+ isSelected?: boolean;
11
+ onNavigate?: (id: string) => void;
12
+ onSelect?: (id: string) => void;
13
+ total: number;
14
+ prevId?: string;
15
+ nextId?: string;
16
+ };
17
+
18
+ const BoundingBoxHighlightRect = ({
19
+ boundingBox,
20
+ currentIndex,
21
+ isSelected,
22
+ onNavigate,
23
+ onSelect,
24
+ total,
25
+ prevId,
26
+ nextId,
27
+ }: Props): JSX.Element => {
28
+ const { id, x, y, width, height } = boundingBox;
29
+
30
+ const style: React.CSSProperties = {
31
+ display: 'block',
32
+ left: `${x}%`,
33
+ top: `${y}%`,
34
+ width: `${width}%`,
35
+ height: `${height}%`,
36
+ };
37
+
38
+ const handleClick = (event: React.MouseEvent): void => {
39
+ event.stopPropagation();
40
+ onSelect?.(id);
41
+ };
42
+
43
+ const handlePrev = (event: React.MouseEvent | KeyboardEvent): void => {
44
+ event.stopPropagation();
45
+ if (prevId) {
46
+ onNavigate?.(prevId);
47
+ }
48
+ };
49
+
50
+ const handleNext = (event: React.MouseEvent | KeyboardEvent): void => {
51
+ event.stopPropagation();
52
+ if (nextId) {
53
+ onNavigate?.(nextId);
54
+ }
55
+ };
56
+
57
+ return (
58
+ <div
59
+ className={classNames('ba-BoundingBoxHighlightRect', { 'is-selected': isSelected })}
60
+ data-testid={`ba-BoundingBoxHighlightRect-${id}`}
61
+ onClick={handleClick}
62
+ role="presentation"
63
+ style={style}
64
+ >
65
+ {isSelected && total > 1 && (
66
+ <BoundingBoxHighlightNav
67
+ currentIndex={currentIndex}
68
+ onNext={handleNext}
69
+ onPrev={handlePrev}
70
+ total={total}
71
+ />
72
+ )}
73
+ </div>
74
+ );
75
+ };
76
+
77
+ export default BoundingBoxHighlightRect;
@@ -0,0 +1,4 @@
1
+ export { default as BoundingBoxHighlightList } from './BoundingBoxHighlightList';
2
+ export { default as BoundingBoxHighlightNav } from './BoundingBoxHighlightNav';
3
+ export { default as BoundingBoxHighlightRect } from './BoundingBoxHighlightRect';
4
+ export type { BoundingBox } from '../../store/boundingBoxHighlights/types';
@@ -0,0 +1,14 @@
1
+ import { defineMessages } from 'react-intl';
2
+
3
+ export default defineMessages({
4
+ ariaLabelViewNextReference: {
5
+ id: 'ba.boundingBoxHighlight.viewNextReference',
6
+ description: 'Aria label for the button to view the next reference',
7
+ defaultMessage: 'View next reference',
8
+ },
9
+ ariaLabelViewPrevReference: {
10
+ id: 'ba.boundingBoxHighlight.viewPrevReference',
11
+ description: 'Aria label for the button to view the previous reference',
12
+ defaultMessage: 'View previous reference',
13
+ },
14
+ });
@@ -33,12 +33,6 @@ export type Props = {
33
33
 
34
34
  const options: Partial<Options> = {
35
35
  modifiers: [
36
- {
37
- name: 'eventListeners',
38
- options: {
39
- scroll: false,
40
- },
41
- },
42
36
  {
43
37
  name: 'offset',
44
38
  options: {
@@ -48,7 +42,6 @@ const options: Partial<Options> = {
48
42
  {
49
43
  name: 'preventOverflow',
50
44
  options: {
51
- altAxis: true,
52
45
  padding: 50,
53
46
  },
54
47
  },
@@ -3,21 +3,19 @@ import Popper from '@popperjs/core';
3
3
  import { useSelector } from 'react-redux';
4
4
  import { useIntl } from 'react-intl';
5
5
  import FocusTrap from 'box-ui-elements/es/components/focus-trap/FocusTrap';
6
+
7
+ import ReplyForm from '../ReplyForm';
8
+
9
+ import { getRotation, getScale } from '../../store/options';
10
+
6
11
  import messages from './messages';
7
12
  import PopupBase from './PopupBase';
8
- import PopupReplyV2 from './PopupReplyV2';
9
- import ReplyForm from '../ReplyForm';
10
- import { getScale, getRotation } from '../../store/options';
11
13
  import { PopupReference } from './Popper';
12
14
  import './PopupReply.scss';
13
15
 
14
- type BaseProps = {
16
+ export type Props = {
15
17
  className?: string;
16
- };
17
-
18
- type LegacyProps = BaseProps & {
19
18
  isPending: boolean;
20
- isThreadedAnnotation?: false;
21
19
  onCancel: (text?: string) => void;
22
20
  onChange: (text?: string) => void;
23
21
  onSubmit: (text: string) => void;
@@ -25,18 +23,6 @@ type LegacyProps = BaseProps & {
25
23
  value?: string;
26
24
  };
27
25
 
28
- type ThreadedProps = BaseProps & {
29
- isThreadedAnnotation: true;
30
- isPending?: boolean;
31
- onCancel?: (text?: string) => void;
32
- onChange?: (text?: string) => void;
33
- onSubmit?: (text: string) => void;
34
- reference?: PopupReference;
35
- value?: string;
36
- };
37
-
38
- export type Props = LegacyProps | ThreadedProps;
39
-
40
26
  const isIE = (): boolean => {
41
27
  const { userAgent } = navigator;
42
28
  return userAgent.indexOf('Trident/') > 0;
@@ -97,7 +83,7 @@ const getOptions = (): Partial<Popper.Options> => {
97
83
  export default function PopupReply(props: Props): JSX.Element {
98
84
  const intl = useIntl();
99
85
  const popupRef = React.useRef<PopupBase>(null);
100
- const popupOptions = React.useRef<Partial<Popper.Options>>(getOptions()); // Keep the options reference the same between renders
86
+ const popupOptions = React.useRef<Partial<Popper.Options>>(getOptions());
101
87
  const rotation = useSelector(getRotation);
102
88
  const scale = useSelector(getScale);
103
89
 
@@ -109,13 +95,7 @@ export default function PopupReply(props: Props): JSX.Element {
109
95
  }
110
96
  }, [popupRef, rotation, scale]);
111
97
 
112
- // TODO: PopupReplyV2 is a placeholder stub. Props (isPending, onCancel, etc.)
113
- // are not yet forwarded and will need to be wired up when V2 is implemented.
114
- if (props.isThreadedAnnotation) {
115
- return <PopupReplyV2 />;
116
- }
117
-
118
- const { isPending, onCancel, onChange, onSubmit, value = '', isThreadedAnnotation: _, ...rest } = props as LegacyProps;
98
+ const { isPending, onCancel, onChange, onSubmit, value = '', ...rest } = props;
119
99
 
120
100
  return (
121
101
  <FocusTrap>
@@ -0,0 +1,28 @@
1
+ .ba-PopupV2 {
2
+ position: absolute;
3
+ top: 0;
4
+ left: 0;
5
+ z-index: 150;
6
+ width: 348px;
7
+ }
8
+
9
+ // Specificity must beat .ba selectors from box-ui-elements globals
10
+ // (forms, buttons, links) that cascade into threaded-annotations components.
11
+ .ba .ba-PopupV2 {
12
+ div[contenteditable='true'],
13
+ div[contenteditable='true']:hover,
14
+ div[contenteditable='true']:focus {
15
+ width: auto;
16
+ padding: 0;
17
+ color: inherit;
18
+ border: none;
19
+ border-radius: 0;
20
+ outline: none;
21
+ box-shadow: none;
22
+ transition: none;
23
+ }
24
+
25
+ button svg {
26
+ pointer-events: auto;
27
+ }
28
+ }
@@ -0,0 +1,382 @@
1
+ import noop from 'lodash/noop';
2
+ import React from 'react';
3
+ import ReactDOM from 'react-dom';
4
+ import { useIntl } from 'react-intl';
5
+ import { useDispatch, useSelector } from 'react-redux';
6
+
7
+ import { BlueprintModernizationProvider, TooltipProvider } from '@box/blueprint-web';
8
+ import {
9
+ MentionContextProvider,
10
+ MessageEditorV2,
11
+ ThreadedAnnotationsV2,
12
+ serializeMentionMarkup,
13
+ } from '@box/threaded-annotations';
14
+ import type { DocumentNodeV2, TextMessageTypeV2 } from '@box/threaded-annotations';
15
+ import type { FetchedAvatarUrls, UserContactType } from '@box/user-selector';
16
+ import type { JSONContent } from '@tiptap/core';
17
+ import FocusTrap from 'box-ui-elements/es/components/focus-trap/FocusTrap';
18
+
19
+ import { annotationToMessages, collaboratorToUserContact } from '../../adapters/threadedAnnotationsAdapters';
20
+ import AnnotationCallbacksContext from '../../common/AnnotationCallbacksContext';
21
+ import {
22
+ createReplyAction,
23
+ deleteAnnotationAction,
24
+ deleteReplyAction,
25
+ setActiveAnnotationIdAction,
26
+ updateAnnotationAction,
27
+ updateReplyAction,
28
+ } from '../../store/annotations/actions';
29
+ import { getAnnotation } from '../../store/annotations/selectors';
30
+ import { getApiHost, getFileId, getFileVersionId, getToken } from '../../store/options';
31
+ import { fetchCollaboratorsAction } from '../../store/users/actions';
32
+
33
+ import type { Token, TokenLiteral, TokenMap } from '../../@types';
34
+ import type { AppState, AppThunkDispatch } from '../../store/types';
35
+
36
+ import createPopper, { PopupReference } from './Popper';
37
+ import type { Instance, Options } from './Popper';
38
+ import messages from './messages';
39
+ import './PopupV2.scss';
40
+
41
+ export type Props = {
42
+ annotationId?: string;
43
+ onSubmit: (text: string) => void;
44
+ popupPortalEl?: HTMLElement | null;
45
+ reference: PopupReference;
46
+ };
47
+
48
+ const getPopupOptions = (): Partial<Options> => ({
49
+ modifiers: [
50
+ { name: 'flip', options: { altAxis: false, fallbackPlacements: ['top', 'left', 'right'] } },
51
+ { name: 'offset', options: { offset: [0, 15] } },
52
+ { name: 'preventOverflow', options: { padding: 5 } },
53
+ ],
54
+ placement: 'bottom',
55
+ });
56
+
57
+ const createDocumentNode = (content: JSONContent | null): DocumentNodeV2 => {
58
+ if (!content) {
59
+ return { type: 'doc', content: [] };
60
+ }
61
+
62
+ if (content.type === 'doc' && content.content) {
63
+ return content as DocumentNodeV2;
64
+ }
65
+
66
+ return { type: 'doc', content: [content] } as DocumentNodeV2;
67
+ };
68
+
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> => {
89
+ try {
90
+ if (!fileId) return null;
91
+ const stringToken = await resolveStringToken(token, `file_${fileId}`);
92
+ if (!stringToken) return null;
93
+ const response = await fetch(`${apiHost}/2.0/users/${userId}/avatar?pic_type=large`, {
94
+ headers: { Authorization: `Bearer ${stringToken}` },
95
+ });
96
+ if (!response.ok) return null;
97
+ const blob = await response.blob();
98
+ return URL.createObjectURL(blob);
99
+ } catch {
100
+ return null;
101
+ }
102
+ };
103
+
104
+ const PopupV2 = ({ annotationId, onSubmit, popupPortalEl, reference }: Props): JSX.Element | null => {
105
+ const intl = useIntl();
106
+ const dispatch = useDispatch<AppThunkDispatch>();
107
+ const { onCopyLink: consumerOnCopyLink } = React.useContext(AnnotationCallbacksContext);
108
+ const popupRef = React.useRef<HTMLDivElement>(null);
109
+ const popperRef = React.useRef<Instance>();
110
+ const optionsRef = React.useRef<Partial<Options>>(getPopupOptions());
111
+
112
+ const apiHost = useSelector(getApiHost);
113
+ const fileId = useSelector(getFileId);
114
+ const fileVersionId = useSelector(getFileVersionId);
115
+ const token = useSelector(getToken);
116
+ const onCopyLink = React.useMemo(
117
+ () =>
118
+ consumerOnCopyLink && annotationId && fileVersionId
119
+ ? () => consumerOnCopyLink({ annotationId, fileVersionId })
120
+ : undefined,
121
+ [consumerOnCopyLink, annotationId, fileVersionId],
122
+ );
123
+ const annotation = useSelector((state: AppState) =>
124
+ annotationId ? getAnnotation(state, annotationId) : undefined,
125
+ );
126
+
127
+ const [avatarBlobs, setAvatarBlobs] = React.useState<Record<string, string>>({});
128
+ const avatarCacheRef = React.useRef<Map<string, string>>(new Map());
129
+ const credentialsRef = React.useRef({ apiHost, token });
130
+ credentialsRef.current = { apiHost, token };
131
+
132
+ const getOrFetchAvatarBlob = React.useCallback(
133
+ async (userId: string): Promise<string | null> => {
134
+ const cached = avatarCacheRef.current.get(userId);
135
+ if (cached) return cached;
136
+ const capturedApiHost = apiHost;
137
+ const capturedToken = token;
138
+ const url = await fetchAvatarBlob(capturedApiHost, capturedToken, fileId, userId);
139
+ if (!url) return null;
140
+ if (
141
+ credentialsRef.current.apiHost !== capturedApiHost ||
142
+ credentialsRef.current.token !== capturedToken
143
+ ) {
144
+ URL.revokeObjectURL(url);
145
+ return null;
146
+ }
147
+ const existing = avatarCacheRef.current.get(userId);
148
+ if (existing) {
149
+ URL.revokeObjectURL(url);
150
+ return existing;
151
+ }
152
+ avatarCacheRef.current.set(userId, url);
153
+ return url;
154
+ },
155
+ [apiHost, fileId, token],
156
+ );
157
+
158
+ React.useEffect(() => {
159
+ if (popupRef.current) {
160
+ popperRef.current = createPopper(reference, popupRef.current, optionsRef.current);
161
+ }
162
+
163
+ return () => {
164
+ popperRef.current?.destroy();
165
+ };
166
+ }, [reference]);
167
+
168
+ React.useEffect(() => {
169
+ const cache = avatarCacheRef.current;
170
+ return () => {
171
+ cache.forEach(url => URL.revokeObjectURL(url));
172
+ cache.clear();
173
+ };
174
+ }, []);
175
+
176
+ React.useEffect(() => {
177
+ avatarCacheRef.current.forEach(url => URL.revokeObjectURL(url));
178
+ avatarCacheRef.current.clear();
179
+ setAvatarBlobs({});
180
+ }, [apiHost, token]);
181
+
182
+ const handleEvent = React.useCallback((event: React.SyntheticEvent) => {
183
+ event.stopPropagation();
184
+ }, []);
185
+
186
+ React.useEffect(() => {
187
+ if (!annotation) return undefined;
188
+
189
+ const userIds = Array.from(
190
+ new Set(annotationToMessages(annotation).map(msg => String(msg.author.id))),
191
+ );
192
+ let cancelled = false;
193
+
194
+ Promise.all(userIds.map(async id => [id, await getOrFetchAvatarBlob(id)] as const)).then(entries => {
195
+ if (cancelled) return;
196
+ setAvatarBlobs(prev => {
197
+ const next = { ...prev };
198
+ entries.forEach(([id, url]) => {
199
+ if (url) next[id] = url;
200
+ });
201
+ return next;
202
+ });
203
+ });
204
+
205
+ return () => {
206
+ cancelled = true;
207
+ };
208
+ }, [annotation, getOrFetchAvatarBlob]);
209
+
210
+ const threadMessages: TextMessageTypeV2[] = React.useMemo(() => {
211
+ if (!annotation) return [];
212
+ return annotationToMessages(annotation).map(msg => ({
213
+ ...msg,
214
+ author: {
215
+ ...msg.author,
216
+ avatarUrl: avatarBlobs[String(msg.author.id)],
217
+ },
218
+ }));
219
+ }, [annotation, avatarBlobs]);
220
+
221
+ const isResolved = annotation?.status === 'resolved';
222
+ const resolvedBy = isResolved
223
+ ? annotation?.resolution?.resolved_by?.name ?? annotation?.modified_by?.name
224
+ : undefined;
225
+ const resolvedAtSource = isResolved
226
+ ? annotation?.resolution?.resolved_at ?? annotation?.modified_at
227
+ : undefined;
228
+ const resolvedAt = resolvedAtSource ? new Date(resolvedAtSource).getTime() : undefined;
229
+
230
+ const userSelectorProps = React.useMemo(
231
+ () => ({
232
+ allowEmptyQuery: true,
233
+ ariaRoleDescription: intl.formatMessage(messages.ariaLabelMentionSelector),
234
+ fetchAvatarUrls: async (userContacts: UserContactType[]): Promise<FetchedAvatarUrls> => {
235
+ const urls: FetchedAvatarUrls = {};
236
+ await Promise.all(
237
+ userContacts.map(async ({ id }) => {
238
+ const key = String(id);
239
+ const blobUrl = await getOrFetchAvatarBlob(key);
240
+ if (blobUrl) {
241
+ urls[key] = blobUrl;
242
+ }
243
+ }),
244
+ );
245
+ return urls;
246
+ },
247
+ fetchUsers: async (query: string) => {
248
+ const action = await dispatch(fetchCollaboratorsAction(query));
249
+ if (fetchCollaboratorsAction.fulfilled.match(action)) {
250
+ return action.payload.entries.map(collaboratorToUserContact);
251
+ }
252
+ return [];
253
+ },
254
+ loadingAriaLabel: intl.formatMessage(messages.ariaLabelMentionLoading),
255
+ }),
256
+ [dispatch, getOrFetchAvatarBlob, intl],
257
+ );
258
+
259
+ const handlePost = React.useCallback(
260
+ async (content: JSONContent | null): Promise<void> => {
261
+ const doc = createDocumentNode(content);
262
+ const { text } = serializeMentionMarkup(doc);
263
+ if (annotationId) {
264
+ await dispatch(createReplyAction({ annotationId, message: text }));
265
+ } else {
266
+ onSubmit(text);
267
+ }
268
+ },
269
+ [annotationId, dispatch, onSubmit],
270
+ );
271
+
272
+ const handleEdit = React.useCallback(
273
+ async (id: string, content: JSONContent | null): Promise<void> => {
274
+ if (!annotationId) return;
275
+ const doc = createDocumentNode(content);
276
+ const { text } = serializeMentionMarkup(doc);
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 } }));
282
+ },
283
+ [annotationId, dispatch],
284
+ );
285
+
286
+ const handleThreadDelete = React.useCallback(
287
+ async (): Promise<void> => {
288
+ if (annotationId) {
289
+ await dispatch(deleteAnnotationAction(annotationId));
290
+ }
291
+ },
292
+ [annotationId, dispatch],
293
+ );
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
+
303
+ const handleResolve = React.useCallback(
304
+ async (): Promise<void> => {
305
+ if (!annotationId) return;
306
+ const result = await dispatch(updateAnnotationAction({ annotationId, payload: { status: 'resolved' } }));
307
+ if (updateAnnotationAction.fulfilled.match(result)) {
308
+ dispatch(setActiveAnnotationIdAction(null));
309
+ }
310
+ },
311
+ [annotationId, dispatch],
312
+ );
313
+
314
+ const handleUnresolve = React.useCallback(
315
+ async (): Promise<void> => {
316
+ if (!annotationId) return;
317
+ await dispatch(updateAnnotationAction({ annotationId, payload: { status: 'open' } }));
318
+ },
319
+ [annotationId, dispatch],
320
+ );
321
+
322
+ if (!popupPortalEl) return null;
323
+
324
+ return ReactDOM.createPortal(
325
+ <FocusTrap>
326
+ <div
327
+ ref={popupRef}
328
+ aria-label={intl.formatMessage(messages.ariaLabelComment)}
329
+ className="ba-PopupV2"
330
+ data-resin-component={annotationId ? 'popupThreadV2' : 'popupReplyV2'}
331
+ onClick={handleEvent}
332
+ onMouseDown={handleEvent}
333
+ onMouseMove={handleEvent}
334
+ onMouseUp={handleEvent}
335
+ onTouchMove={handleEvent}
336
+ onTouchStart={handleEvent}
337
+ role="presentation"
338
+ >
339
+ <BlueprintModernizationProvider enableModernizedComponents>
340
+ <TooltipProvider>
341
+ <MentionContextProvider value={{
342
+ collaborationPopoverProps: {
343
+ onSubmit: () => Promise.resolve(),
344
+ },
345
+ // Mentions are restricted to file collaborators; the invite popover is intentionally never shown.
346
+ fetchCollaboratorState: async () => true,
347
+ }}>
348
+ {annotationId ? (
349
+ <ThreadedAnnotationsV2
350
+ isAnnotations
351
+ isResolved={isResolved}
352
+ messages={threadMessages}
353
+ onAvatarClick={noop}
354
+ onCopyLink={onCopyLink}
355
+ onDelete={handleDelete}
356
+ onEdit={handleEdit}
357
+ onPost={handlePost}
358
+ onResolve={handleResolve}
359
+ onThreadDelete={handleThreadDelete}
360
+ onUnresolve={handleUnresolve}
361
+ resolvedAt={resolvedAt}
362
+ resolvedBy={resolvedBy}
363
+ userSelectorProps={userSelectorProps}
364
+ />
365
+ ) : (
366
+ <MessageEditorV2
367
+ isFirstAnnotation
368
+ onPost={handlePost}
369
+ userSelectorProps={userSelectorProps}
370
+ />
371
+ )}
372
+ </MentionContextProvider>
373
+ </TooltipProvider>
374
+ </BlueprintModernizationProvider>
375
+ <div data-threaded-annotations-portal />
376
+ </div>
377
+ </FocusTrap>,
378
+ popupPortalEl,
379
+ );
380
+ };
381
+
382
+ 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',