@@ -10,6 +10,7 @@ import {
1010 serializeMentionMarkup ,
1111} from '@box/threaded-annotations' ;
1212import type { DocumentNodeV2 , TextMessageTypeV2 } from '@box/threaded-annotations' ;
13+ import type { FetchedAvatarUrls , UserContactType } from '@box/user-selector' ;
1314import type { JSONContent } from '@tiptap/core' ;
1415import FocusTrap from 'box-ui-elements/es/components/focus-trap/FocusTrap' ;
1516
@@ -21,6 +22,7 @@ import {
2122 updateAnnotationAction ,
2223} from '../../store/annotations/actions' ;
2324import { getAnnotation } from '../../store/annotations/selectors' ;
25+ import { getApiHost , getToken } from '../../store/options' ;
2426import { fetchCollaboratorsAction } from '../../store/users/actions' ;
2527
2628import type { AppState , AppThunkDispatch } from '../../store/types' ;
@@ -57,17 +59,65 @@ const createDocumentNode = (content: JSONContent | null): DocumentNodeV2 => {
5759 return { type : 'doc' , content : [ content ] } as DocumentNodeV2 ;
5860} ;
5961
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+
6077const PopupV2 = ( { annotationId, onSubmit, reference } : Props ) : JSX . Element => {
6178 const intl = useIntl ( ) ;
6279 const dispatch = useDispatch < AppThunkDispatch > ( ) ;
6380 const popupRef = React . useRef < HTMLDivElement > ( null ) ;
6481 const popperRef = React . useRef < Instance > ( ) ;
6582 const optionsRef = React . useRef < Partial < Options > > ( getPopupOptions ( ) ) ;
6683
84+ const apiHost = useSelector ( getApiHost ) ;
85+ const token = useSelector ( getToken ) ;
6786 const annotation = useSelector ( ( state : AppState ) =>
6887 annotationId ? getAnnotation ( state , annotationId ) : undefined ,
6988 ) ;
7089
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+
71121 React . useEffect ( ( ) => {
72122 if ( popupRef . current ) {
73123 popperRef . current = createPopper ( reference , popupRef . current , optionsRef . current ) ;
@@ -78,20 +128,85 @@ const PopupV2 = ({ annotationId, onSubmit, reference }: Props): JSX.Element => {
78128 } ;
79129 } , [ reference ] ) ;
80130
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+
81145 const handleEvent = React . useCallback ( ( event : React . SyntheticEvent ) => {
82146 event . stopPropagation ( ) ;
83147 } , [ ] ) ;
84148
85- const threadMessages : TextMessageTypeV2 [ ] = React . useMemo (
86- ( ) => ( annotation ? annotationToMessages ( annotation ) : [ ] ) ,
87- [ annotation ] ,
88- ) ;
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 ;
89192
90193 const userSelectorProps = React . useMemo (
91194 ( ) => ( {
92195 allowEmptyQuery : true ,
93196 ariaRoleDescription : intl . formatMessage ( messages . ariaLabelMentionSelector ) ,
94- fetchAvatarUrls : async ( ) => ( { } ) ,
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+ } ,
95210 fetchUsers : async ( query : string ) => {
96211 const action = await dispatch ( fetchCollaboratorsAction ( query ) ) ;
97212 if ( fetchCollaboratorsAction . fulfilled . match ( action ) ) {
@@ -101,7 +216,7 @@ const PopupV2 = ({ annotationId, onSubmit, reference }: Props): JSX.Element => {
101216 } ,
102217 loadingAriaLabel : intl . formatMessage ( messages . ariaLabelMentionLoading ) ,
103218 } ) ,
104- [ dispatch , intl ] ,
219+ [ dispatch , getOrFetchAvatarBlob , intl ] ,
105220 ) ;
106221
107222 const handlePost = React . useCallback (
@@ -169,13 +284,16 @@ const PopupV2 = ({ annotationId, onSubmit, reference }: Props): JSX.Element => {
169284 { annotationId ? (
170285 < ThreadedAnnotationsV2
171286 isAnnotations
287+ isResolved = { isResolved }
172288 messages = { threadMessages }
173289 onAvatarClick = { noop }
174290 onDelete = { noop }
175291 onPost = { handlePost }
176292 onResolve = { handleResolve }
177293 onThreadDelete = { handleThreadDelete }
178294 onUnresolve = { handleUnresolve }
295+ resolvedAt = { resolvedAt }
296+ resolvedBy = { resolvedBy }
179297 userSelectorProps = { userSelectorProps }
180298 />
181299 ) : (
0 commit comments