newspack-components 4.2.0 → 4.4.0-epic-editor-refactor.1

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 (315) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/DEVELOPMENT.md +868 -0
  3. package/copy-styles.js +3 -2
  4. package/dist/cjs/accordion/index.js +17 -12
  5. package/dist/cjs/action-card/index.js +186 -140
  6. package/dist/cjs/action-card/style.scss +25 -2
  7. package/dist/cjs/autocomplete-tokenfield/index.js +254 -234
  8. package/dist/cjs/autocomplete-with-latest-posts/index.js +351 -0
  9. package/dist/cjs/autocomplete-with-latest-posts/style.scss +150 -0
  10. package/dist/cjs/autocomplete-with-suggestions/index.js +229 -126
  11. package/dist/cjs/badge/index.js +32 -0
  12. package/dist/cjs/badge/style.scss +36 -0
  13. package/dist/cjs/box-contrast/index.js +19 -15
  14. package/dist/cjs/button/index.js +41 -24
  15. package/dist/cjs/button-props/button-props.js +5 -5
  16. package/dist/cjs/button-props/index.js +2 -2
  17. package/dist/cjs/card/core-card.js +199 -0
  18. package/dist/cjs/card/index.js +75 -35
  19. package/dist/cjs/card/style-core.scss +295 -0
  20. package/dist/cjs/card/style.scss +4 -4
  21. package/dist/cjs/card-feature/index.js +141 -0
  22. package/dist/cjs/card-feature/style.scss +75 -0
  23. package/dist/cjs/card-form/index.js +144 -0
  24. package/dist/cjs/card-form/style.scss +20 -0
  25. package/dist/cjs/card-settings-group/index.js +69 -0
  26. package/dist/cjs/card-settings-group/style.scss +33 -0
  27. package/dist/cjs/card-sortable-list/index.js +426 -0
  28. package/dist/cjs/card-sortable-list/style.scss +48 -0
  29. package/dist/cjs/category-autocomplete/index.js +175 -155
  30. package/dist/cjs/color-picker/index.js +41 -23
  31. package/dist/cjs/color-picker/index.test.js +82 -0
  32. package/dist/cjs/color-picker/style.scss +11 -13
  33. package/dist/cjs/confirm-dialog/index.js +123 -0
  34. package/dist/cjs/consts.js +1 -1
  35. package/dist/cjs/custom-select-control/index.js +39 -28
  36. package/dist/cjs/dataviews/index.js +38 -0
  37. package/dist/cjs/dataviews/style.scss +9 -0
  38. package/dist/cjs/divider/index.js +47 -0
  39. package/dist/cjs/divider/style.scss +51 -0
  40. package/dist/cjs/footer/index.js +36 -30
  41. package/dist/cjs/form-token-field/index.js +51 -40
  42. package/dist/cjs/form-token-field/style.scss +0 -5
  43. package/dist/cjs/global-notices/index.js +5 -5
  44. package/dist/cjs/grid/index.js +25 -18
  45. package/dist/cjs/grid/style.scss +67 -8
  46. package/dist/cjs/handoff/index.js +204 -156
  47. package/dist/cjs/handoff-message/index.js +12 -7
  48. package/dist/cjs/hooks/index.js +10 -8
  49. package/dist/cjs/hooks/use-confirm-dialog.js +79 -0
  50. package/dist/cjs/hooks/use-unsaved-changes-dialog.js +144 -0
  51. package/dist/cjs/hooks/useObjectState.js +21 -12
  52. package/dist/cjs/hooks/useObjectState.test.js +55 -33
  53. package/dist/cjs/hooks/useOnClickOutside.js +6 -6
  54. package/dist/cjs/hooks/usePrompt.js +10 -13
  55. package/dist/cjs/image-upload/index.js +114 -123
  56. package/dist/cjs/image-upload/index.test.js +11 -13
  57. package/dist/cjs/image-upload/style.scss +12 -40
  58. package/dist/cjs/index.js +166 -92
  59. package/dist/cjs/info-button/index.js +43 -32
  60. package/dist/cjs/integration-icons/active-campaign.js +22 -0
  61. package/dist/cjs/integration-icons/constant-contact.js +28 -0
  62. package/dist/cjs/integration-icons/fundraise-up.js +25 -0
  63. package/dist/cjs/integration-icons/index.js +48 -0
  64. package/dist/cjs/integration-icons/mailchimp.js +19 -0
  65. package/dist/cjs/integration-icons/salesforce.js +17 -0
  66. package/dist/cjs/integration-icons/wisepops.js +20 -0
  67. package/dist/cjs/modal/index.js +29 -19
  68. package/dist/cjs/modal/style.scss +47 -13
  69. package/dist/cjs/newspack-icon/index.js +45 -38
  70. package/dist/cjs/newspack-icon/style.scss +5 -3
  71. package/dist/cjs/notice/index.js +64 -55
  72. package/dist/cjs/plugin-installer/index.js +259 -253
  73. package/dist/cjs/plugin-settings/SettingsSection.js +66 -60
  74. package/dist/cjs/plugin-settings/index.js +217 -200
  75. package/dist/cjs/plugin-toggle/index.js +192 -199
  76. package/dist/cjs/popover/index.js +41 -28
  77. package/dist/cjs/position-control/index.js +24 -23
  78. package/dist/cjs/progress-bar/index.js +71 -59
  79. package/dist/cjs/progress-bar/index.test.js +62 -68
  80. package/dist/cjs/proxied-imports/router.js +3 -3
  81. package/dist/cjs/radio-control/index.js +40 -27
  82. package/dist/cjs/section-header/index.js +143 -39
  83. package/dist/cjs/section-header/style.scss +129 -4
  84. package/dist/cjs/select-control/ButtonGroupControl.js +26 -21
  85. package/dist/cjs/select-control/GroupedSelectControl.js +41 -35
  86. package/dist/cjs/select-control/index.js +52 -40
  87. package/dist/cjs/settings/MinMaxSetting.js +30 -20
  88. package/dist/cjs/settings/SettingsCard.js +20 -17
  89. package/dist/cjs/settings/SettingsSection.js +22 -21
  90. package/dist/cjs/settings/index.js +5 -5
  91. package/dist/cjs/sortable-newsletter-list-control/index.js +78 -57
  92. package/dist/cjs/steps-list/index.js +44 -34
  93. package/dist/cjs/steps-list-item/index.js +47 -39
  94. package/dist/cjs/style-card/index.js +70 -63
  95. package/dist/cjs/style.scss +2 -0
  96. package/dist/cjs/tabbed-navigation/index.js +38 -36
  97. package/dist/cjs/text-control/index.js +27 -25
  98. package/dist/cjs/utils/color.js +4 -4
  99. package/dist/cjs/utils/editor-toolbar-back-button.js +61 -0
  100. package/dist/cjs/utils/index.js +31 -19
  101. package/dist/cjs/utils/style.scss +8 -0
  102. package/dist/cjs/waiting/index.js +48 -35
  103. package/dist/cjs/waiting/style.scss +4 -0
  104. package/dist/cjs/web-preview/index.js +219 -154
  105. package/dist/cjs/web-preview/style.scss +20 -53
  106. package/dist/cjs/with-wizard/index.js +362 -364
  107. package/dist/cjs/with-wizard/style.scss +28 -60
  108. package/dist/cjs/with-wizard-screen/index.js +54 -58
  109. package/dist/cjs/with-wizard-screen/style.scss +77 -10
  110. package/dist/cjs/wizard/components/WizardError.js +20 -18
  111. package/dist/cjs/wizard/components/WizardSnackbar.js +73 -0
  112. package/dist/cjs/wizard/components/style.scss +48 -0
  113. package/dist/cjs/wizard/index.js +207 -64
  114. package/dist/cjs/wizard/store/index.js +191 -97
  115. package/dist/cjs/wizard/store/utils.js +13 -8
  116. package/dist/esm/accordion/index.js +14 -9
  117. package/dist/esm/action-card/index.js +178 -124
  118. package/dist/esm/action-card/style.scss +25 -2
  119. package/dist/esm/autocomplete-tokenfield/index.js +248 -222
  120. package/dist/esm/autocomplete-with-latest-posts/index.js +347 -0
  121. package/dist/esm/autocomplete-with-latest-posts/style.scss +150 -0
  122. package/dist/esm/autocomplete-with-suggestions/index.js +226 -119
  123. package/dist/esm/badge/index.js +24 -0
  124. package/dist/esm/badge/style.scss +36 -0
  125. package/dist/esm/box-contrast/index.js +15 -11
  126. package/dist/esm/button/index.js +39 -19
  127. package/dist/esm/button-props/button-props.js +1 -1
  128. package/dist/esm/card/core-card.js +195 -0
  129. package/dist/esm/card/index.js +70 -22
  130. package/dist/esm/card/style-core.scss +295 -0
  131. package/dist/esm/card/style.scss +4 -4
  132. package/dist/esm/card-feature/index.js +131 -0
  133. package/dist/esm/card-feature/style.scss +75 -0
  134. package/dist/esm/card-form/index.js +134 -0
  135. package/dist/esm/card-form/style.scss +20 -0
  136. package/dist/esm/card-settings-group/index.js +61 -0
  137. package/dist/esm/card-settings-group/style.scss +33 -0
  138. package/dist/esm/card-sortable-list/index.js +425 -0
  139. package/dist/esm/card-sortable-list/style.scss +48 -0
  140. package/dist/esm/category-autocomplete/index.js +168 -140
  141. package/dist/esm/color-picker/index.js +39 -21
  142. package/dist/esm/color-picker/index.test.js +78 -0
  143. package/dist/esm/color-picker/style.scss +11 -13
  144. package/dist/esm/confirm-dialog/index.js +116 -0
  145. package/dist/esm/consts.js +1 -1
  146. package/dist/esm/custom-select-control/index.js +34 -15
  147. package/dist/esm/dataviews/index.js +34 -0
  148. package/dist/esm/dataviews/style.scss +9 -0
  149. package/dist/esm/divider/index.js +42 -0
  150. package/dist/esm/divider/style.scss +51 -0
  151. package/dist/esm/footer/index.js +34 -28
  152. package/dist/esm/form-token-field/index.js +46 -27
  153. package/dist/esm/form-token-field/style.scss +0 -5
  154. package/dist/esm/global-notices/index.js +3 -3
  155. package/dist/esm/grid/index.js +23 -13
  156. package/dist/esm/grid/style.scss +67 -8
  157. package/dist/esm/handoff/index.js +199 -143
  158. package/dist/esm/handoff-message/index.js +10 -6
  159. package/dist/esm/hooks/index.js +8 -6
  160. package/dist/esm/hooks/use-confirm-dialog.js +73 -0
  161. package/dist/esm/hooks/use-unsaved-changes-dialog.js +136 -0
  162. package/dist/esm/hooks/useObjectState.js +19 -9
  163. package/dist/esm/hooks/useObjectState.test.js +54 -32
  164. package/dist/esm/hooks/useOnClickOutside.js +4 -4
  165. package/dist/esm/hooks/usePrompt.js +9 -11
  166. package/dist/esm/image-upload/index.js +110 -111
  167. package/dist/esm/image-upload/index.test.js +11 -13
  168. package/dist/esm/image-upload/style.scss +12 -40
  169. package/dist/esm/index.js +13 -1
  170. package/dist/esm/info-button/index.js +38 -19
  171. package/dist/esm/integration-icons/active-campaign.js +16 -0
  172. package/dist/esm/integration-icons/constant-contact.js +22 -0
  173. package/dist/esm/integration-icons/fundraise-up.js +19 -0
  174. package/dist/esm/integration-icons/index.js +6 -0
  175. package/dist/esm/integration-icons/mailchimp.js +13 -0
  176. package/dist/esm/integration-icons/salesforce.js +11 -0
  177. package/dist/esm/integration-icons/wisepops.js +14 -0
  178. package/dist/esm/modal/index.js +27 -13
  179. package/dist/esm/modal/style.scss +47 -13
  180. package/dist/esm/newspack-icon/index.js +40 -25
  181. package/dist/esm/newspack-icon/style.scss +5 -3
  182. package/dist/esm/notice/index.js +59 -42
  183. package/dist/esm/plugin-installer/index.js +255 -243
  184. package/dist/esm/plugin-settings/SettingsSection.js +61 -50
  185. package/dist/esm/plugin-settings/index.js +212 -189
  186. package/dist/esm/plugin-toggle/index.js +187 -190
  187. package/dist/esm/popover/index.js +35 -16
  188. package/dist/esm/position-control/index.js +22 -18
  189. package/dist/esm/progress-bar/index.js +66 -49
  190. package/dist/esm/progress-bar/index.test.js +61 -67
  191. package/dist/esm/radio-control/index.js +34 -15
  192. package/dist/esm/section-header/index.js +140 -38
  193. package/dist/esm/section-header/style.scss +129 -4
  194. package/dist/esm/select-control/ButtonGroupControl.js +23 -18
  195. package/dist/esm/select-control/GroupedSelectControl.js +38 -30
  196. package/dist/esm/select-control/index.js +47 -27
  197. package/dist/esm/settings/MinMaxSetting.js +27 -16
  198. package/dist/esm/settings/SettingsCard.js +18 -13
  199. package/dist/esm/settings/SettingsSection.js +20 -19
  200. package/dist/esm/settings/index.js +3 -3
  201. package/dist/esm/sortable-newsletter-list-control/index.js +74 -53
  202. package/dist/esm/steps-list/index.js +39 -21
  203. package/dist/esm/steps-list-item/index.js +42 -26
  204. package/dist/esm/style-card/index.js +65 -50
  205. package/dist/esm/style.scss +2 -0
  206. package/dist/esm/tabbed-navigation/index.js +35 -33
  207. package/dist/esm/text-control/index.js +25 -19
  208. package/dist/esm/utils/color.js +4 -4
  209. package/dist/esm/utils/editor-toolbar-back-button.js +53 -0
  210. package/dist/esm/utils/index.js +29 -16
  211. package/dist/esm/utils/style.scss +8 -0
  212. package/dist/esm/waiting/index.js +43 -22
  213. package/dist/esm/waiting/style.scss +4 -0
  214. package/dist/esm/web-preview/index.js +218 -147
  215. package/dist/esm/web-preview/style.scss +20 -53
  216. package/dist/esm/with-wizard/index.js +360 -358
  217. package/dist/esm/with-wizard/style.scss +28 -60
  218. package/dist/esm/with-wizard-screen/index.js +48 -46
  219. package/dist/esm/with-wizard-screen/style.scss +77 -10
  220. package/dist/esm/wizard/components/WizardError.js +18 -16
  221. package/dist/esm/wizard/components/WizardSnackbar.js +70 -0
  222. package/dist/esm/wizard/components/style.scss +48 -0
  223. package/dist/esm/wizard/index.js +207 -58
  224. package/dist/esm/wizard/store/index.js +189 -90
  225. package/dist/esm/wizard/store/utils.js +13 -6
  226. package/package.json +30 -17
  227. package/release.config.js +1 -0
  228. package/shared/author-context.js +26 -0
  229. package/shared/hooks/use-coauthors.js +388 -0
  230. package/shared/hooks/use-coauthors.test.js +850 -0
  231. package/shared/hooks/use-custom-byline.js +47 -0
  232. package/shared/hooks/use-custom-byline.test.js +133 -0
  233. package/shared/scss/_mixins.scss +54 -0
  234. package/src/accordion/index.js +8 -7
  235. package/src/action-card/action-card.d.ts +7 -3
  236. package/src/action-card/index.js +39 -12
  237. package/src/action-card/style.scss +25 -2
  238. package/src/autocomplete-tokenfield/index.js +2 -2
  239. package/src/autocomplete-with-latest-posts/index.js +290 -0
  240. package/src/autocomplete-with-latest-posts/style.scss +150 -0
  241. package/src/badge/index.tsx +26 -0
  242. package/src/badge/style.scss +36 -0
  243. package/src/button/index.tsx +3 -2
  244. package/src/card/core-card.js +224 -0
  245. package/src/card/index.js +40 -1
  246. package/src/card/style-core.scss +295 -0
  247. package/src/card/style.scss +4 -4
  248. package/src/card-feature/README.md +188 -0
  249. package/src/card-feature/index.tsx +186 -0
  250. package/src/card-feature/style.scss +75 -0
  251. package/src/card-form/README.md +113 -0
  252. package/src/card-form/index.tsx +128 -0
  253. package/src/card-form/style.scss +20 -0
  254. package/src/card-settings-group/README.md +43 -0
  255. package/src/card-settings-group/index.tsx +78 -0
  256. package/src/card-settings-group/style.scss +33 -0
  257. package/src/card-sortable-list/README.md +51 -0
  258. package/src/card-sortable-list/index.tsx +435 -0
  259. package/src/card-sortable-list/style.scss +48 -0
  260. package/src/color-picker/index.js +15 -8
  261. package/src/color-picker/index.test.js +52 -0
  262. package/src/color-picker/style.scss +11 -13
  263. package/src/confirm-dialog/README.md +128 -0
  264. package/src/confirm-dialog/index.tsx +132 -0
  265. package/src/dataviews/index.tsx +34 -0
  266. package/src/dataviews/style.scss +9 -0
  267. package/src/divider/README.md +25 -0
  268. package/src/divider/index.js +31 -0
  269. package/src/divider/style.scss +51 -0
  270. package/src/form-token-field/style.scss +0 -5
  271. package/src/grid/index.tsx +2 -1
  272. package/src/grid/style.scss +67 -8
  273. package/src/handoff/README.md +93 -0
  274. package/src/handoff/index.js +46 -8
  275. package/src/hooks/index.ts +2 -1
  276. package/src/hooks/use-confirm-dialog.tsx +80 -0
  277. package/src/hooks/use-unsaved-changes-dialog.tsx +140 -0
  278. package/src/image-upload/index.js +10 -15
  279. package/src/image-upload/style.scss +12 -40
  280. package/src/index.js +13 -1
  281. package/src/integration-icons/active-campaign.js +14 -0
  282. package/src/integration-icons/constant-contact.js +22 -0
  283. package/src/integration-icons/fundraise-up.js +18 -0
  284. package/src/integration-icons/index.js +6 -0
  285. package/src/integration-icons/mailchimp.js +10 -0
  286. package/src/integration-icons/salesforce.js +10 -0
  287. package/src/integration-icons/wisepops.js +14 -0
  288. package/src/modal/index.js +12 -3
  289. package/src/modal/style.scss +47 -13
  290. package/src/newspack-icon/style.scss +5 -3
  291. package/src/plugin-installer/index.js +30 -19
  292. package/src/section-header/index.js +105 -14
  293. package/src/section-header/style.scss +129 -4
  294. package/src/select-control/index.js +4 -0
  295. package/src/style.scss +2 -0
  296. package/src/utils/editor-toolbar-back-button.tsx +48 -0
  297. package/src/utils/index.js +6 -0
  298. package/src/utils/style.scss +8 -0
  299. package/src/waiting/index.js +2 -1
  300. package/src/waiting/style.scss +4 -0
  301. package/src/web-preview/index.js +90 -35
  302. package/src/web-preview/style.scss +20 -53
  303. package/src/with-wizard/index.js +1 -1
  304. package/src/with-wizard/style.scss +28 -60
  305. package/src/with-wizard-screen/style.scss +77 -10
  306. package/src/wizard/components/WizardSnackbar.js +58 -0
  307. package/src/wizard/components/style.scss +48 -0
  308. package/src/wizard/index.js +152 -21
  309. package/src/wizard/store/index.js +28 -2
  310. package/dist/cjs/button-card/index.js +0 -72
  311. package/dist/cjs/button-card/style.scss +0 -180
  312. package/dist/esm/button-card/index.js +0 -64
  313. package/dist/esm/button-card/style.scss +0 -180
  314. package/src/button-card/index.js +0 -52
  315. package/src/button-card/style.scss +0 -180
@@ -0,0 +1,388 @@
1
+ /**
2
+ * WordPress dependencies
3
+ */
4
+ import { useSelect } from '@wordpress/data';
5
+ import { useState, useEffect, useMemo } from '@wordpress/element';
6
+ import { store as coreStore } from '@wordpress/core-data';
7
+ import apiFetch from '@wordpress/api-fetch';
8
+
9
+ // Detection key for the optional Redux store published by CAP <4.0. When present and exposing
10
+ // `getAuthors`, prefer the synchronous path; otherwise fall back to term-ID resolution via REST.
11
+ const CAP_STORE = 'cap/authors';
12
+ const COAUTHORS_ENDPOINT = '/coauthors/v1/coauthors';
13
+ const COAUTHORS_BY_TERM_IDS_ENDPOINT = '/coauthors/v1/authors-by-term-ids';
14
+
15
+ // Module-level cache for guest author avatar URLs, keyed by user_nicename.
16
+ // Prevents duplicate REST requests when components re-mount or when
17
+ // multiple avatar blocks in a Query Loop share the same guest authors.
18
+ const guestAvatarCache = {};
19
+
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
+ // Term-ID to author object cache (new CAP). A cached `false` means the server omitted the ID.
27
+ const coauthorDetailsCache = {};
28
+
29
+ // In-flight term-ID fetches keyed by sorted CSV, so concurrent mounts share one REST call.
30
+ const inflightTermResolves = {};
31
+
32
+ // Stable empty-state reference so setState is a no-op when already empty.
33
+ const EMPTY_AUTHORS = Object.freeze( [] );
34
+
35
+ /**
36
+ * Fetch coauthor details for an array of taxonomy term IDs (new CAP).
37
+ * Deduplicates concurrent requests and caches results by term ID.
38
+ *
39
+ * @param {number[]} termIds Array of coauthor taxonomy term IDs.
40
+ * @return {Promise} Resolves once results are in `coauthorDetailsCache`.
41
+ */
42
+ function fetchCoauthorsByTermIds( termIds ) {
43
+ if ( ! termIds || termIds.length === 0 ) {
44
+ return Promise.resolve();
45
+ }
46
+ const uncachedIds = termIds.filter( id => ! ( id in coauthorDetailsCache ) );
47
+ if ( uncachedIds.length === 0 ) {
48
+ return Promise.resolve();
49
+ }
50
+ const key = [ ...uncachedIds ].sort( ( a, b ) => a - b ).join( ',' );
51
+ if ( inflightTermResolves[ key ] ) {
52
+ return inflightTermResolves[ key ];
53
+ }
54
+ inflightTermResolves[ key ] = apiFetch( {
55
+ path: `${ COAUTHORS_BY_TERM_IDS_ENDPOINT }?ids=${ uncachedIds.join( ',' ) }`,
56
+ } )
57
+ .then( results => {
58
+ if ( Array.isArray( results ) ) {
59
+ results.forEach( author => {
60
+ if ( author?.termId ) {
61
+ coauthorDetailsCache[ author.termId ] = author;
62
+ }
63
+ } );
64
+ }
65
+ // Mark IDs that the server omitted as permanently unresolved (e.g. deleted author).
66
+ // Only done on a successful response — transient errors leave cache untouched to allow retries.
67
+ uncachedIds.forEach( id => {
68
+ if ( ! ( id in coauthorDetailsCache ) ) {
69
+ coauthorDetailsCache[ id ] = false;
70
+ }
71
+ } );
72
+ } )
73
+ .catch( () => {
74
+ // Do not poison the cache on transient errors (network, 5xx) or permission errors (403).
75
+ // Concurrent calls are already deduped via `inflightTermResolves`, and leaving the cache
76
+ // untouched allows a later call to `fetchCoauthorsByTermIds` to retry after this
77
+ // in-flight request clears. Failure is signalled to consumers via `hasCoauthorTermIds`
78
+ // remaining true while `authors` stays empty.
79
+ } )
80
+ .finally( () => {
81
+ delete inflightTermResolves[ key ];
82
+ } );
83
+ return inflightTermResolves[ key ];
84
+ }
85
+
86
+ /**
87
+ * Fetch a single coauthor's avatar URLs, deduplicating concurrent requests.
88
+ *
89
+ * @param {string} nicename Author nicename (slug).
90
+ * @return {Promise} Resolves when the fetch completes (result is stored in guestAvatarCache).
91
+ */
92
+ function fetchCoauthorAvatar( nicename ) {
93
+ if ( nicename in guestAvatarCache ) {
94
+ return Promise.resolve();
95
+ }
96
+ if ( inflightRequests[ nicename ] ) {
97
+ return inflightRequests[ nicename ];
98
+ }
99
+ inflightRequests[ nicename ] = apiFetch( {
100
+ path: `${ COAUTHORS_ENDPOINT }/${ encodeURIComponent( nicename ) }`,
101
+ } )
102
+ .then( result => {
103
+ guestAvatarCache[ nicename ] = result?.avatar_urls || false;
104
+ } )
105
+ .catch( () => {
106
+ guestAvatarCache[ nicename ] = false;
107
+ } )
108
+ .finally( () => {
109
+ delete inflightRequests[ nicename ];
110
+ } );
111
+ return inflightRequests[ nicename ];
112
+ }
113
+
114
+ /**
115
+ * Reset the module-level guest avatar cache. Exposed for testing only.
116
+ */
117
+ export function resetGuestAvatarCacheForTests() {
118
+ Object.keys( guestAvatarCache ).forEach( key => delete guestAvatarCache[ key ] );
119
+ Object.keys( inflightRequests ).forEach( key => delete inflightRequests[ key ] );
120
+ }
121
+
122
+ /**
123
+ * Reset the module-level coauthor details cache. Exposed for testing only.
124
+ */
125
+ export function resetCoauthorDetailsCacheForTests() {
126
+ Object.keys( coauthorDetailsCache ).forEach( key => delete coauthorDetailsCache[ key ] );
127
+ Object.keys( inflightTermResolves ).forEach( key => delete inflightTermResolves[ key ] );
128
+ }
129
+
130
+ /**
131
+ * Extract user_nicename from an author archive URL.
132
+ *
133
+ * @param {string} link Author archive URL (e.g. /author/jane/).
134
+ * @return {string|undefined} Extracted nicename, or undefined.
135
+ */
136
+ function extractNicenameFromLink( link ) {
137
+ if ( ! link ) {
138
+ return undefined;
139
+ }
140
+ const cleaned = link.split( '?' )[ 0 ].split( '#' )[ 0 ].replace( /\/$/, '' );
141
+ const segments = cleaned.split( '/' );
142
+ const slug = segments.pop();
143
+ // Require a parent path segment so root URLs and plain permalinks are rejected.
144
+ const parent = segments[ segments.length - 1 ];
145
+ if ( ! slug || ! parent ) {
146
+ return undefined;
147
+ }
148
+ return slug;
149
+ }
150
+
151
+ /**
152
+ * Hook to get CoAuthors Plus authors from the CAP store or REST API.
153
+ *
154
+ * For the currently-edited post, uses one of two sources depending on which CAP version is active:
155
+ * - Legacy CAP (Redux store published by CAP <4.0): reads from `cap/authors` for real-time updates.
156
+ * - New CAP (no Redux store, CAP 4.0+): reads taxonomy term IDs from
157
+ * `getEditedPostAttribute('coauthors')` and resolves them to rich author data via the
158
+ * `authors-by-term-ids` REST endpoint.
159
+ * For Query Loop posts, it uses REST data from `newspack_author_info` (unaffected by CAP version).
160
+ *
161
+ * @param {number} postId Post ID to get authors for.
162
+ * @param {string} postType Post type (default: 'post').
163
+ * @param {boolean} skip Skip fetching (default: false).
164
+ * @return {Object} `{ authors, isCapAvailable, isLoading, hasCoauthorTermIds }`.
165
+ * `isLoading` is true during the new-CAP term-ID resolution window (including the
166
+ * pre-flight render before the effect fires); legacy CAP is synchronous.
167
+ * `hasCoauthorTermIds` is true when the new-CAP path is in use AND term IDs are
168
+ * assigned — consumers should treat `authors.length === 0` while this flag is true
169
+ * as "resolution unavailable" (e.g. permission-denied REST) rather than "no coauthors",
170
+ * to avoid silently falling back to default state.
171
+ */
172
+ export function useCoAuthors( postId, postType = 'post', skip = false ) {
173
+ // Return raw store references from useSelect to avoid creating new objects
174
+ // on every render (which triggers useSelect memoization warnings).
175
+ // The .map() transformations happen in useMemo below.
176
+ const { legacyCapAuthors, coauthorTermIdsKey, restAuthors, isCapAvailable } = useSelect(
177
+ select => {
178
+ if ( skip ) {
179
+ return { legacyCapAuthors: null, coauthorTermIdsKey: null, restAuthors: null, isCapAvailable: false };
180
+ }
181
+
182
+ const capStore = select( CAP_STORE );
183
+ const isLegacyCapStoreAvailable = Boolean( capStore && typeof capStore.getAuthors === 'function' );
184
+
185
+ const editorStore = select( 'core/editor' );
186
+ const currentPostId = editorStore?.getCurrentPostId?.();
187
+ const isQueryLoopContext = postId && currentPostId && postId !== currentPostId;
188
+
189
+ // Query Loop: read from newspack_author_info on the post entity.
190
+ // Unaffected by CAP version.
191
+ if ( isQueryLoopContext && postId ) {
192
+ const { getEntityRecord } = select( coreStore );
193
+ const post = getEntityRecord( 'postType', postType, postId );
194
+ const restData = post?.newspack_author_info || null;
195
+ return {
196
+ legacyCapAuthors: null,
197
+ coauthorTermIdsKey: null,
198
+ restAuthors: restData,
199
+ isCapAvailable: restData ? true : isLegacyCapStoreAvailable,
200
+ };
201
+ }
202
+
203
+ // Legacy CAP: read from cap/authors store for real-time updates.
204
+ if ( isLegacyCapStoreAvailable ) {
205
+ const rawCapAuthors = postId ? capStore.getAuthors( postId ) : null;
206
+ return { legacyCapAuthors: rawCapAuthors, coauthorTermIdsKey: null, restAuthors: null, isCapAvailable: true };
207
+ }
208
+
209
+ // New CAP: read taxonomy term IDs from the post entity. Serialize to a stable string
210
+ // inside the selector so a fresh array reference from `getEditedPostAttribute` doesn't
211
+ // trigger spurious re-renders. `null` = attribute not loaded; `''` = loaded but empty;
212
+ // CSV (e.g. `'471,488'`) = term IDs assigned.
213
+ const termIds = editorStore?.getEditedPostAttribute?.( 'coauthors' );
214
+ if ( Array.isArray( termIds ) ) {
215
+ const validIds = termIds.map( Number ).filter( Number.isInteger );
216
+ return {
217
+ legacyCapAuthors: null,
218
+ coauthorTermIdsKey: validIds.length === 0 ? '' : validIds.join( ',' ),
219
+ restAuthors: null,
220
+ isCapAvailable: true,
221
+ };
222
+ }
223
+
224
+ // No CAP detected (plugin inactive).
225
+ return { legacyCapAuthors: null, coauthorTermIdsKey: null, restAuthors: null, isCapAvailable: false };
226
+ },
227
+ [ postId, postType, skip ]
228
+ );
229
+
230
+ // Resolve coauthor term IDs to author data via REST (new CAP path only).
231
+ // Uses a module-level cache + in-flight dedup so concurrent mounts share a single fetch.
232
+ const [ resolvedTermAuthors, setResolvedTermAuthors ] = useState( EMPTY_AUTHORS );
233
+ const [ asyncIsLoading, setAsyncIsLoading ] = useState( false );
234
+ // Tracks the last `coauthorTermIdsKey` the effect has processed. Used to derive a synchronous
235
+ // pre-flight loading state during the render between key change and effect firing — without
236
+ // it, consumers briefly see "term IDs assigned but isLoading false" and may pick wrong defaults.
237
+ const [ processedKey, setProcessedKey ] = useState( null );
238
+
239
+ const hasCoauthorTermIds = coauthorTermIdsKey !== null && coauthorTermIdsKey !== '';
240
+ const isPreFlight = hasCoauthorTermIds && processedKey !== coauthorTermIdsKey;
241
+ const isLoading = isPreFlight || asyncIsLoading;
242
+
243
+ useEffect( () => {
244
+ if ( skip || coauthorTermIdsKey === null ) {
245
+ // Use the module-level frozen empty array so the identity is stable
246
+ // and setState is a no-op when already empty.
247
+ setResolvedTermAuthors( EMPTY_AUTHORS );
248
+ setAsyncIsLoading( false );
249
+ setProcessedKey( coauthorTermIdsKey );
250
+ return;
251
+ }
252
+ const ids = coauthorTermIdsKey === '' ? [] : coauthorTermIdsKey.split( ',' ).map( Number ).filter( Number.isInteger );
253
+ if ( ids.length === 0 ) {
254
+ setResolvedTermAuthors( EMPTY_AUTHORS );
255
+ setAsyncIsLoading( false );
256
+ setProcessedKey( coauthorTermIdsKey );
257
+ return;
258
+ }
259
+
260
+ // If everything is already cached, resolve synchronously without flipping loading state.
261
+ const allCached = ids.every( id => id in coauthorDetailsCache );
262
+ if ( allCached ) {
263
+ const resolved = ids.map( id => coauthorDetailsCache[ id ] ).filter( author => author && typeof author === 'object' );
264
+ setResolvedTermAuthors( resolved.length === 0 ? EMPTY_AUTHORS : resolved );
265
+ setAsyncIsLoading( false );
266
+ setProcessedKey( coauthorTermIdsKey );
267
+ return;
268
+ }
269
+
270
+ let cancelled = false;
271
+ setAsyncIsLoading( true );
272
+ fetchCoauthorsByTermIds( ids ).then( () => {
273
+ if ( cancelled ) {
274
+ return;
275
+ }
276
+ const resolved = ids.map( id => coauthorDetailsCache[ id ] ).filter( author => author && typeof author === 'object' );
277
+ setResolvedTermAuthors( resolved.length === 0 ? EMPTY_AUTHORS : resolved );
278
+ setAsyncIsLoading( false );
279
+ setProcessedKey( coauthorTermIdsKey );
280
+ } );
281
+
282
+ return () => {
283
+ cancelled = true;
284
+ };
285
+ }, [ coauthorTermIdsKey, skip ] );
286
+
287
+ // Map raw store data to our normalized author format.
288
+ const authors = useMemo( () => {
289
+ // Legacy CAP store authors: { id, label, display, value, userType }
290
+ if ( legacyCapAuthors && legacyCapAuthors.length > 0 ) {
291
+ return legacyCapAuthors.map( author => ( {
292
+ id: author.id,
293
+ display_name: author.display || author.value || author.label,
294
+ user_nicename: author.value,
295
+ isGuest: author.userType === 'guest-author',
296
+ } ) );
297
+ }
298
+
299
+ // New CAP authors resolved from term IDs.
300
+ // REST shape: { id (string), termId, displayName, userNicename, userType, ... }
301
+ // Coerce `id` to Number at the source so consumers and the in-file dedupe (`other.id === author.id`)
302
+ // see a consistent shape across legacy CAP (numeric id) and new CAP (string id).
303
+ if ( resolvedTermAuthors && resolvedTermAuthors.length > 0 ) {
304
+ return resolvedTermAuthors.map( author => ( {
305
+ id: Number( author.id ),
306
+ termId: author.termId,
307
+ display_name: author.displayName,
308
+ user_nicename: author.userNicename,
309
+ isGuest: author.userType === 'guest-author',
310
+ } ) );
311
+ }
312
+
313
+ // REST API authors from newspack_author_info (Query Loop).
314
+ if ( restAuthors && Array.isArray( restAuthors ) && restAuthors.length > 0 ) {
315
+ return restAuthors.map( author => ( {
316
+ id: author.id,
317
+ display_name: author.display_name,
318
+ author_link: author.author_link,
319
+ user_nicename: author.user_nicename || extractNicenameFromLink( author.author_link ),
320
+ ...( typeof author.is_guest === 'boolean' ? { isGuest: author.is_guest } : {} ),
321
+ ...( author.avatar_urls ? { avatar_urls: author.avatar_urls } : {} ),
322
+ } ) );
323
+ }
324
+
325
+ return [];
326
+ }, [ legacyCapAuthors, resolvedTermAuthors, restAuthors ] );
327
+
328
+ // Fetch avatar URLs from the CAP REST API for authors that need it.
329
+ // The CAP store strips avatar data via formatAuthorData(), so we need
330
+ // to fetch the raw REST response to get the avatar URL (especially for
331
+ // guest authors whose avatars come from featured images).
332
+ //
333
+ // Fetches per-author (by nicename) instead of per-post so that avatars
334
+ // resolve immediately when a guest author is added — before the post is saved.
335
+ //
336
+ // For currently-edited post authors, isGuest is explicitly true/false.
337
+ // For Query Loop authors, isGuest is undefined (we can't distinguish from
338
+ // REST data alone), so we fetch for all of them and let the consumer
339
+ // (hooks.js) prefer WP user data when available.
340
+ const [ avatarMap, setAvatarMap ] = useState( {} );
341
+
342
+ // Build a stable key from author nicenames to control effect re-runs.
343
+ // isGuest !== false: includes guest authors (true) and Query Loop authors (undefined),
344
+ // but excludes known WP users from the currently-edited post (false).
345
+ const authorsNeedingAvatars = authors.filter( author => author.isGuest !== false && author.user_nicename && ! author.avatar_urls );
346
+ const avatarNicenames = authorsNeedingAvatars.map( author => author.user_nicename ).join( ',' );
347
+
348
+ useEffect( () => {
349
+ if ( skip || ! isCapAvailable || ! avatarNicenames ) {
350
+ return;
351
+ }
352
+
353
+ let cancelled = false;
354
+ Promise.all( authorsNeedingAvatars.map( a => fetchCoauthorAvatar( a.user_nicename ) ) ).then( () => {
355
+ if ( cancelled ) {
356
+ return;
357
+ }
358
+ const map = {};
359
+ authorsNeedingAvatars.forEach( a => {
360
+ if ( guestAvatarCache[ a.user_nicename ] ) {
361
+ map[ a.id ] = guestAvatarCache[ a.user_nicename ];
362
+ }
363
+ } );
364
+ setAvatarMap( map );
365
+ } );
366
+
367
+ return () => {
368
+ cancelled = true;
369
+ };
370
+ }, [ skip, isCapAvailable, avatarNicenames ] );
371
+
372
+ // Merge avatar URLs into guest authors.
373
+ // Check both the async avatarMap (populated by the effect) and the
374
+ // synchronous guestAvatarCache (populated by previous fetches) so that
375
+ // cached avatars render immediately without waiting for an effect cycle.
376
+ const authorsWithAvatars = authors.map( author => {
377
+ if ( author.isGuest === false || ! author.user_nicename ) {
378
+ return author;
379
+ }
380
+ const urls = avatarMap[ author.id ] || guestAvatarCache[ author.user_nicename ];
381
+ if ( ! urls ) {
382
+ return author;
383
+ }
384
+ return { ...author, avatar_urls: urls };
385
+ } );
386
+
387
+ return { authors: authorsWithAvatars, isCapAvailable, isLoading, hasCoauthorTermIds };
388
+ }