PluginProbe ʕ •ᴥ•ʔ
Presto Player / 4.3.2
Presto Player v4.3.2
4.3.2 4.3.1 4.3.0 4.2.4 4.2.3 4.2.2 4.2.0 4.2.1 trunk 1.10.0 1.10.1 1.10.2 1.11.0 1.12.0 1.13.0 1.14.0 1.14.1 1.5.10 1.5.11 1.5.12 1.5.13 1.5.14 1.5.15 1.5.5 1.5.6 1.5.7 1.5.8 1.5.9 1.6.0 1.6.1 1.6.10 1.6.11 1.6.12 1.6.13 1.6.2 1.6.3 1.6.4 1.6.5 1.6.6 1.6.7 1.6.8 1.6.9 1.7.0 1.7.1 1.7.2 1.8.0 1.8.1 1.8.2 1.8.3 1.8.4 1.8.5 1.8.6 1.9.0 1.9.1 1.9.10 1.9.11 1.9.12 1.9.13 1.9.14 1.9.2 1.9.3 1.9.4 1.9.5 1.9.6 1.9.7 1.9.8 1.9.9 2.0.0 2.0.1 2.0.10 2.0.11 2.0.12 2.0.13 2.0.14 2.0.15 2.0.16 2.0.2 2.0.3 2.0.4 2.0.5 2.0.6 2.0.7 2.0.8 2.0.9 2.1.0 2.2.0 2.2.1 2.2.2 2.2.3 2.2.3-beta1 2.3.0 2.3.1 2.3.2 2.3.3 3.0.0 3.0.0-beta1 3.0.1 3.0.2 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.0.8 3.1.0 3.1.1 3.1.2 3.1.3 4.0.0 4.0.1 4.0.2 4.0.3 4.0.4 4.0.5 4.0.6 4.0.7 4.0.8 4.1.0 4.1.1 4.1.2 4.1.3 4.1.4
presto-player / src / admin / dashboard / hooks / useMediaList.ts
presto-player / src / admin / dashboard / hooks Last commit date
test 1 month ago useCompleteOnboarding.js 1 month ago useDateRangePicker.js 2 months ago useEmail.ts 2 months ago useEngagementChartData.js 2 months ago useLicenseSettings.js 2 months ago useLink.js 2 months ago useMediaDetail.js 2 months ago useMediaLibrary.js 2 months ago useMediaList.ts 1 month ago usePerformanceSettings.js 2 months ago useRegisterActivePage.js 2 months ago useSettingOption.js 2 months ago useSimpleSettingsPage.js 2 months ago useTopPerforming.js 2 months ago useTopVideosPaginated.js 2 months ago useUpgradeCTA.js 2 months ago useUserDetail.js 2 months ago
useMediaList.ts
320 lines
1 import { useState, useEffect, useRef, useCallback } from '@wordpress/element';
2 import apiFetch from '@wordpress/api-fetch';
3 import { addQueryArgs } from '@wordpress/url';
4 import { __, sprintf } from '@wordpress/i18n';
5 import { decodeHTMLEntities } from '../utils/formatters';
6
7 /**
8 * Server-driven Media Hub list hook.
9 *
10 * One request per page view: search, status, tag, orderby, order, page and
11 * per_page are pushed to the server, which returns just the rows for the
12 * current page along with the total counts and tag list. There is no
13 * eager full-library fetch and no client-side filter / sort / slice.
14 *
15 * Mirrors the existing paginated-hook pattern in useTopVideosPaginated.js:
16 * `parse: false` + AbortController on every param change so an in-flight
17 * request is cancelled the moment the user types another keystroke or
18 * switches tabs.
19 */
20
21 const ENDPOINT = '/presto-player/v1/media-list';
22
23 export interface MediaTag {
24 id: number;
25 name: string;
26 slug: string;
27 }
28
29 export interface MediaItem {
30 id: number;
31 title: string;
32 status: string;
33 date: string;
34 modified: string;
35 post_name: string;
36 // Only present when caller can edit_post (WP REST context=edit).
37 post_password?: string;
38 shortcode: string;
39 poster: string;
40 author: { id: number; name: string };
41 tags: MediaTag[];
42 link: string;
43 }
44
45 export interface MediaListCounts {
46 publish: number;
47 draft: number;
48 pending: number;
49 private: number;
50 future: number;
51 trash: number;
52 }
53
54 export interface UseMediaListParams {
55 search?: string;
56 status?: string;
57 tag?: number;
58 orderby?: 'date' | 'modified' | 'title';
59 order?: 'asc' | 'desc';
60 page?: number;
61 perPage?: number;
62 }
63
64 export interface UseMediaListReturn {
65 items: MediaItem[];
66 totalItems: number;
67 totalPages: number;
68 counts: MediaListCounts;
69 allTags: MediaTag[];
70 loading: boolean;
71 error: string | null;
72 /**
73 * Flips to true once the first response (success OR failure) has
74 * settled. Consumers use it to distinguish "first-paint skeleton" from
75 * "in-place refetch" without depending on counts.
76 */
77 hasLoadedOnce: boolean;
78 refetch: () => void;
79 }
80
81 const EMPTY_COUNTS: MediaListCounts = {
82 publish: 0,
83 draft: 0,
84 pending: 0,
85 private: 0,
86 future: 0,
87 trash: 0,
88 };
89
90 const DEFAULT_STATUSES = 'publish,draft,pending,private,future';
91
92 interface RawTag {
93 id?: unknown;
94 name?: unknown;
95 slug?: unknown;
96 }
97
98 const normalizeTags = ( raw: unknown ): MediaTag[] => {
99 if ( ! Array.isArray( raw ) ) {
100 return [];
101 }
102 const out: MediaTag[] = [];
103 for ( const candidate of raw ) {
104 const t = candidate as RawTag;
105 const id = Number( t?.id );
106 if ( ! Number.isFinite( id ) || id <= 0 || ! t?.name ) {
107 continue;
108 }
109 out.push( {
110 id,
111 name: String( t.name ),
112 slug: typeof t.slug === 'string' ? t.slug : '',
113 } );
114 }
115 return out;
116 };
117
118 // Sanitize a row on the boundary. The server already strips WP's
119 // "Protected: " / "Private: " prefix and decodes entities, but we mirror
120 // the consumer-side decode here so titles still render correctly if the
121 // server is ever bypassed by a fixture. Empty titles fall back to
122 // `Media #<id>` so an untitled draft renders as a recognisable row rather
123 // than a blank cell.
124 const normalizeItem = ( raw: unknown ): MediaItem => {
125 const r = ( raw ?? {} ) as Record< string, unknown >;
126 const authorRaw = ( r.author ?? {} ) as Record< string, unknown >;
127
128 const id = Number( r.id ) || 0;
129 const decoded = decodeHTMLEntities( String( r.title ?? '' ) ).trim();
130 const title =
131 decoded ||
132 ( id > 0
133 ? sprintf(
134 /* translators: %d: id of an untitled media item */
135 __( 'Media #%d', 'presto-player' ),
136 id
137 )
138 : '' );
139
140 return {
141 id,
142 title,
143 status: String( r.status ?? 'publish' ),
144 date: String( r.date ?? '' ),
145 modified: String( r.modified ?? '' ),
146 post_name: String( r.post_name ?? '' ),
147 post_password: String( r.post_password ?? '' ),
148 shortcode: String( r.shortcode ?? '' ),
149 poster: String( r.poster ?? '' ),
150 author: {
151 id: Number( authorRaw.id ) || 0,
152 name: String( authorRaw.name ?? '' ),
153 },
154 tags: normalizeTags( r.tags ),
155 link: String( r.link ?? '' ),
156 };
157 };
158
159 /**
160 * Read the JSON body of a Response-like object, swallowing parse errors.
161 * Used both on the success path (apiFetch resolves the Response) and inside
162 * the catch (apiFetch with `parse: false` THROWS the raw Response on non-2xx
163 * — the thrown value is a Response, not an Error, and its body holds the
164 * WP REST error envelope `{ code, message, data }`).
165 */
166 const readJsonBody = async ( response: {
167 json: () => Promise< any >;
168 } ): Promise< any > => {
169 try {
170 return await response.json();
171 } catch {
172 return {};
173 }
174 };
175
176 /**
177 * Duck-type check for a Response-like rejection from apiFetch. Avoids
178 * `instanceof Response` because jsdom (Jest's test runtime) doesn't define
179 * the global `Response` constructor.
180 */
181 const isResponseLike = (
182 err: unknown
183 ): err is { status: number; json: () => Promise< any > } => {
184 if ( ! err || typeof err !== 'object' ) {
185 return false;
186 }
187 const e = err as { status?: unknown; json?: unknown };
188 return typeof e.status === 'number' && typeof e.json === 'function';
189 };
190
191 /**
192 * Extract a human-readable message from whatever `apiFetch` rejected with.
193 * Three shapes are possible:
194 *
195 * 1. AbortError — caller cancelled; handled separately by `isAbort()`.
196 * 2. Response-like — apiFetch's behaviour when `parse: false`; the body
197 * holds the WP REST error envelope.
198 * 3. Plain Error / object — middleware-generated (offline, nonce refresh
199 * failure); already has `.message`.
200 */
201 const messageFromRejection = async ( err: unknown ): Promise< string > => {
202 if ( isResponseLike( err ) ) {
203 const body = await readJsonBody( err );
204 if ( typeof body?.message === 'string' && body.message ) {
205 return body.message;
206 }
207 return `HTTP ${ err.status }`;
208 }
209 const maybeMessage = ( err as { message?: unknown } )?.message;
210 if ( typeof maybeMessage === 'string' && maybeMessage ) {
211 return maybeMessage;
212 }
213 return __( 'Failed to load media.', 'presto-player' );
214 };
215
216 const isAbort = ( err: unknown ): boolean =>
217 ( err as { name?: string } )?.name === 'AbortError';
218
219 const useMediaList = ( {
220 search = '',
221 status = DEFAULT_STATUSES,
222 tag = 0,
223 orderby = 'date',
224 order = 'desc',
225 page = 1,
226 perPage = 25,
227 }: UseMediaListParams = {} ): UseMediaListReturn => {
228 const [ items, setItems ] = useState< MediaItem[] >( [] );
229 const [ totalItems, setTotalItems ] = useState( 0 );
230 const [ totalPages, setTotalPages ] = useState( 0 );
231 const [ counts, setCounts ] = useState< MediaListCounts >( EMPTY_COUNTS );
232 const [ allTags, setAllTags ] = useState< MediaTag[] >( [] );
233 const [ loading, setLoading ] = useState( true );
234 const [ error, setError ] = useState< string | null >( null );
235 const [ hasLoadedOnce, setHasLoadedOnce ] = useState( false );
236 // Bump to force a refetch with identical params (e.g. after a mutation).
237 const [ refetchKey, setRefetchKey ] = useState( 0 );
238 const abortRef = useRef< AbortController | null >( null );
239
240 const refetch = useCallback( () => {
241 setRefetchKey( ( k ) => k + 1 );
242 }, [] );
243
244 useEffect( () => {
245 if ( abortRef.current ) {
246 abortRef.current.abort();
247 }
248 const controller = new AbortController();
249 abortRef.current = controller;
250
251 ( async () => {
252 try {
253 setLoading( true );
254 // `parse: false` lets us own pagination headers; the trade-off
255 // is that apiFetch THROWS the raw Response on non-2xx instead
256 // of resolving it, so the error envelope is recovered in the
257 // catch block via `messageFromRejection`.
258 const response = ( await apiFetch( {
259 path: addQueryArgs( ENDPOINT, {
260 search,
261 status,
262 tag,
263 orderby,
264 order,
265 page,
266 per_page: perPage,
267 } ),
268 parse: false,
269 signal: controller.signal,
270 } ) ) as Response;
271
272 const json = await readJsonBody( response );
273
274 if ( controller.signal.aborted ) {
275 return;
276 }
277
278 setItems(
279 Array.isArray( json?.items ) ? json.items.map( normalizeItem ) : []
280 );
281 setTotalItems( Number( json?.total ) || 0 );
282 setTotalPages( Number( json?.total_pages ) || 0 );
283 setCounts( { ...EMPTY_COUNTS, ...( json?.counts || {} ) } );
284 setAllTags( normalizeTags( json?.all_tags ) );
285 setError( null );
286 } catch ( err ) {
287 if ( isAbort( err ) || controller.signal.aborted ) {
288 return;
289 }
290 const message = await messageFromRejection( err );
291 setError( message );
292 setItems( [] );
293 setTotalItems( 0 );
294 setTotalPages( 0 );
295 } finally {
296 if ( ! controller.signal.aborted ) {
297 setLoading( false );
298 setHasLoadedOnce( true );
299 }
300 }
301 } )();
302
303 return () => controller.abort();
304 }, [ search, status, tag, orderby, order, page, perPage, refetchKey ] );
305
306 return {
307 items,
308 totalItems,
309 totalPages,
310 counts,
311 allTags,
312 loading,
313 error,
314 hasLoadedOnce,
315 refetch,
316 };
317 };
318
319 export default useMediaList;
320