Skip to content

Commit edc6a4f

Browse files
authored
fix(avatar): resolve guest author avatars in editor (#4460)
1 parent 66074ff commit edc6a4f

4 files changed

Lines changed: 368 additions & 36 deletions

File tree

src/blocks/avatar/class-avatar-block.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ public static function register_block_styles() {
3636
register_block_style(
3737
'newspack/avatar',
3838
[
39-
'name' => 'stacked',
40-
'label' => __( 'Stacked', 'newspack-plugin' ),
39+
'name' => 'overlapped',
40+
'label' => __( 'Overlapped', 'newspack-plugin' ),
4141
]
4242
);
4343
}

src/blocks/avatar/style.scss

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
display: flex;
44
gap: 0.5rem;
55

6-
&.is-style-stacked,
7-
&[class*="is-style-stacked"] {
6+
&.is-style-overlapped,
7+
&[class*="is-style-overlapped"] {
88
gap: 0;
99

1010
.newspack-avatar-wrapper:not(:last-of-type) {

src/shared/hooks/use-coauthors.js

Lines changed: 100 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,74 @@ import apiFetch from '@wordpress/api-fetch';
1212
const CAP_STORE = 'cap/authors';
1313
const COAUTHORS_ENDPOINT = '/coauthors/v1/coauthors';
1414

15-
// Module-level cache for guest author avatar URLs, keyed by post ID.
15+
// Module-level cache for guest author avatar URLs, keyed by user_nicename.
1616
// Prevents duplicate REST requests when components re-mount or when
1717
// multiple avatar blocks in a Query Loop share the same guest authors.
1818
const guestAvatarCache = {};
1919

20+
// In-flight request promises, keyed by user_nicename.
21+
// Prevents duplicate concurrent requests when multiple block instances
22+
// mount simultaneously (e.g. Query Loop) and all check the cache before
23+
// any fetch has completed.
24+
const inflightRequests = {};
25+
26+
/**
27+
* Fetch a single coauthor's avatar URLs, deduplicating concurrent requests.
28+
*
29+
* @param {string} nicename Author nicename (slug).
30+
* @return {Promise} Resolves when the fetch completes (result is stored in guestAvatarCache).
31+
*/
32+
function fetchCoauthorAvatar( nicename ) {
33+
if ( guestAvatarCache[ nicename ] ) {
34+
return Promise.resolve();
35+
}
36+
if ( inflightRequests[ nicename ] ) {
37+
return inflightRequests[ nicename ];
38+
}
39+
inflightRequests[ nicename ] = apiFetch( {
40+
path: `${ COAUTHORS_ENDPOINT }/${ encodeURIComponent( nicename ) }`,
41+
} )
42+
.then( result => {
43+
if ( result?.avatar_urls ) {
44+
guestAvatarCache[ nicename ] = result.avatar_urls;
45+
}
46+
} )
47+
.catch( () => {} ) // Silently skip failed fetches.
48+
.finally( () => {
49+
delete inflightRequests[ nicename ];
50+
} );
51+
return inflightRequests[ nicename ];
52+
}
53+
54+
/**
55+
* Reset the module-level guest avatar cache. Exposed for testing only.
56+
*/
57+
export function resetGuestAvatarCacheForTests() {
58+
Object.keys( guestAvatarCache ).forEach( key => delete guestAvatarCache[ key ] );
59+
Object.keys( inflightRequests ).forEach( key => delete inflightRequests[ key ] );
60+
}
61+
62+
/**
63+
* Extract user_nicename from an author archive URL.
64+
*
65+
* @param {string} link Author archive URL (e.g. /author/jane/).
66+
* @return {string|undefined} Extracted nicename, or undefined.
67+
*/
68+
function extractNicenameFromLink( link ) {
69+
if ( ! link ) {
70+
return undefined;
71+
}
72+
const cleaned = link.split( '?' )[ 0 ].split( '#' )[ 0 ].replace( /\/$/, '' );
73+
const segments = cleaned.split( '/' );
74+
const slug = segments.pop();
75+
// Require a parent path segment so root URLs and plain permalinks are rejected.
76+
const parent = segments[ segments.length - 1 ];
77+
if ( ! slug || ! parent ) {
78+
return undefined;
79+
}
80+
return slug;
81+
}
82+
2083
/**
2184
* Hook to get CoAuthors Plus authors from the CAP store or REST API.
2285
*
@@ -74,10 +137,13 @@ export function useCoAuthors( postId, postType = 'post', skip = false ) {
74137

75138
if ( restAuthors && Array.isArray( restAuthors ) && restAuthors.length > 0 ) {
76139
// Map REST API author objects to our expected format.
140+
// Extract user_nicename from author_link so the avatar
141+
// effect can fetch from the CAP single-author endpoint.
77142
const mappedAuthors = restAuthors.map( author => ( {
78143
id: author.id,
79144
display_name: author.display_name,
80145
author_link: author.author_link,
146+
user_nicename: extractNicenameFromLink( author.author_link ),
81147
} ) );
82148
return { authors: mappedAuthors, isCapAvailable: true };
83149
}
@@ -89,57 +155,63 @@ export function useCoAuthors( postId, postType = 'post', skip = false ) {
89155
[ postId, postType, skip ]
90156
);
91157

92-
// Fetch avatar URLs from the CAP REST API for guest authors only.
158+
// Fetch avatar URLs from the CAP REST API for authors that need it.
93159
// The CAP store strips avatar data via formatAuthorData(), so we need
94160
// to fetch the raw REST response to get the avatar URL (especially for
95161
// guest authors whose avatars come from featured images).
96-
const [ avatarMap, setAvatarMap ] = useState( () => guestAvatarCache[ postId ] || {} );
97-
98-
// Build a stable key from guest author IDs to avoid re-fetching on every render.
99-
const guestAuthorIds = authors
100-
.filter( author => author.isGuest )
101-
.map( author => author.id )
102-
.join( ',' );
162+
//
163+
// Fetches per-author (by nicename) instead of per-post so that avatars
164+
// resolve immediately when a guest author is added — before the post is saved.
165+
//
166+
// For currently-edited post authors, isGuest is explicitly true/false.
167+
// For Query Loop authors, isGuest is undefined (we can't distinguish from
168+
// REST data alone), so we fetch for all of them and let the consumer
169+
// (hooks.js) prefer WP user data when available.
170+
const [ avatarMap, setAvatarMap ] = useState( {} );
171+
172+
// Build a stable key from author nicenames to control effect re-runs.
173+
// isGuest !== false: includes guest authors (true) and Query Loop authors (undefined),
174+
// but excludes known WP users from the currently-edited post (false).
175+
const authorsNeedingAvatars = authors.filter( author => author.isGuest !== false && author.user_nicename );
176+
const avatarNicenames = authorsNeedingAvatars.map( author => author.user_nicename ).join( ',' );
103177

104178
useEffect( () => {
105-
if ( skip || ! isCapAvailable || ! guestAuthorIds || ! postId ) {
106-
return;
107-
}
108-
109-
// Use cached data if available (avoids duplicate requests on re-mount).
110-
if ( guestAvatarCache[ postId ] ) {
111-
setAvatarMap( guestAvatarCache[ postId ] );
179+
if ( skip || ! isCapAvailable || ! avatarNicenames ) {
112180
return;
113181
}
114182

115183
let cancelled = false;
116-
apiFetch( { path: `${ COAUTHORS_ENDPOINT }?post_id=${ postId }` } ).then( result => {
117-
if ( cancelled || ! Array.isArray( result ) ) {
184+
Promise.all( authorsNeedingAvatars.map( a => fetchCoauthorAvatar( a.user_nicename ) ) ).then( () => {
185+
if ( cancelled ) {
118186
return;
119187
}
120188
const map = {};
121-
result.forEach( item => {
122-
if ( item.id && item.avatar_urls ) {
123-
map[ item.id ] = item.avatar_urls;
189+
authorsNeedingAvatars.forEach( a => {
190+
if ( guestAvatarCache[ a.user_nicename ] ) {
191+
map[ a.id ] = guestAvatarCache[ a.user_nicename ];
124192
}
125193
} );
126-
if ( Object.keys( map ).length ) {
127-
guestAvatarCache[ postId ] = map;
128-
setAvatarMap( map );
129-
}
194+
setAvatarMap( map );
130195
} );
131196

132197
return () => {
133198
cancelled = true;
134199
};
135-
}, [ skip, isCapAvailable, guestAuthorIds, postId ] );
200+
}, [ skip, isCapAvailable, avatarNicenames ] );
136201

137202
// Merge avatar URLs into guest authors.
203+
// Check both the async avatarMap (populated by the effect) and the
204+
// synchronous guestAvatarCache (populated by previous fetches) so that
205+
// cached avatars render immediately without waiting for an effect cycle.
138206
const authorsWithAvatars = authors.map( author => {
139-
if ( ! author.isGuest || ! avatarMap[ author.id ] ) {
207+
if ( author.isGuest === false || ! author.user_nicename ) {
208+
return author;
209+
}
210+
const urls = avatarMap[ author.id ] || guestAvatarCache[ author.user_nicename ];
211+
if ( ! urls ) {
140212
return author;
141213
}
142-
return { ...author, avatar_urls: avatarMap[ author.id ] };
214+
return { ...author, avatar_urls: urls };
143215
} );
144216

145217
return { authors: authorsWithAvatars, isCapAvailable };

0 commit comments

Comments
 (0)