PluginProbe
Extendify / 3.2.2
Extendify v3.2.2
3.2.2 3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 All 128 releases
← All changes | src/Agent/workflows/abilities/components/run/imagify/OptimizeMedia.jsx +257 -40 3.2.0 → 3.2.2 View file →
@@ -1,25 +1,56 @@
1 +import { doReload } from '@agent/lib/reload';
1 2 import executeAbility from '@agent/workflows/abilities/tools/execute-ability';
3 +import apiFetch from '@wordpress/api-fetch';
2 4 import { useEffect, useState } from '@wordpress/element';
3 5 import { __, _n, sprintf } from '@wordpress/i18n';
6 +import { addQueryArgs } from '@wordpress/url';
4 7
5 8 const ABILITY = 'imagify/optimize-media';
9 +const STATUS_ABILITY = 'imagify/get-media-status';
10 +const ACCOUNT_ABILITY = 'imagify/get-account';
11 +const POLL_MS = 2000;
12 +const GIVE_UP_MS = 60000;
6 13
14 +// get-media-status rejects anything outside its own resolver keys.
15 +const mediaInput = (inputs) => ({
16 + ...(inputs?.media_id ? { media_id: inputs.media_id } : {}),
17 + ...(inputs?.media_filename ? { media_filename: inputs.media_filename } : {}),
18 + ...(inputs?.media_url ? { media_url: inputs.media_url } : {}),
19 +});
20 +
21 +const ask = async (ability, input) => {
22 + try {
23 + return await executeAbility({ ability, input });
24 + } catch {
25 + return null;
26 + }
27 +};
28 +
7 29 // Imagify translates its own guard messages only where it has language packs.
8 30 const blockedMessage = (status) => {
9 31 if (status === 'invalid_api_key')
10 - return __(
11 - 'Your Imagify API key is missing or invalid. Add a valid key in the Imagify settings.',
12 - 'extendify-local',
13 - );
32 + return __('Imagify needs a valid API key.', 'extendify-local');
14 33 if (status === 'insufficient_quota')
15 - return __(
16 - 'Your Imagify quota is used up. Wait for the next reset or upgrade your plan.',
17 - 'extendify-local',
18 - );
34 + return __('Your Imagify quota is used up.', 'extendify-local');
19 35 return null;
20 36 };
21 37
38 +// Imagify files the real reason where no ability reads it back.
39 +const failureReason = async () => {
40 + const account = await ask(ACCOUNT_ABILITY, {});
41 + if (!account) return null;
42 + if (account.is_api_key_valid === false)
43 + return blockedMessage('invalid_api_key');
44 + const spent = Number(account.consumed_current_month_quota ?? 0);
45 + const quota = Number(account.quota ?? 0);
46 + const extra = Number(account.extra_quota ?? 0);
47 + const extraSpent = Number(account.extra_quota_consumed ?? 0);
48 + if (quota && spent >= quota && extraSpent >= extra)
49 + return blockedMessage('insufficient_quota');
50 + return null;
51 +};
52 +
22 53 const usePreview = (inputs, enabled) => {
23 54 const [preview, setPreview] = useState(null);
24 55
25 56 useEffect(() => {
@@ -41,12 +72,153 @@
41 72
42 73 return preview;
43 74 };
44 75
45 -const Line = ({ children }) => (
46 - <p className="m-0 p-0 text-sm text-gray-900">{children}</p>
76 +// Asked by name, the model sends only media_filename, which WP matches on title.
77 +const mediaPath = ({ media_id: id, media_filename: filename }) => {
78 + if (id) return `/wp/v2/media/${id}`;
79 + if (filename)
80 + return addQueryArgs('/wp/v2/media', { search: filename, per_page: 1 });
81 + return null;
82 +};
83 +
84 +// Each turn remounts the card, and a fresh lookup would blank the thumbnail.
85 +const resolved = new Map();
86 +
87 +const useImageUrl = (inputs) => {
88 + // A turn re-sends inputs by value, so keying on the object never settles.
89 + const path = mediaPath(inputs ?? {});
90 + const [url, setUrl] = useState(
91 + inputs?.media_url ?? resolved.get(path) ?? null,
92 + );
93 +
94 + useEffect(() => {
95 + if (url || !path) return;
96 + let live = true;
97 + apiFetch({ path })
98 + .then((media) => {
99 + const found = Array.isArray(media) ? media[0] : media;
100 + if (found?.source_url) resolved.set(path, found.source_url);
101 + if (live) setUrl(found?.source_url ?? null);
102 + })
103 + .catch(() => {});
104 + return () => {
105 + live = false;
106 + };
107 + }, [path, url]);
108 +
109 + return url;
110 +};
111 +
112 +// A remount cancels the check, so without this the status never settles.
113 +const observed = new Map();
114 +
115 +const IDLE = { size: null, replaced: false, checked: false, failure: null };
116 +
117 +const seededState = (url, originalSize) => {
118 + const length = observed.get(url);
119 + if (!length) return IDLE;
120 + return {
121 + ...IDLE,
122 + size: length === originalSize ? null : length,
123 + checked: true,
124 + };
125 +};
126 +
127 +// Imagify answers once the job is queued, so the file is the only proof it ran.
128 +const useOptimizedSize = (url, originalSize, enabled, mediaKey) => {
129 + const [state, setState] = useState(() => seededState(url, originalSize));
130 + const [unwatchable, setUnwatchable] = useState(false);
131 +
132 + useEffect(() => {
133 + if (!enabled || !url || !originalSize) return;
134 + let live = true;
135 + let timer;
136 + let seenOriginal = false;
137 + const startedAt = Date.now();
138 + const check = async () => {
139 + const response = await fetch(`${url}?extendify=${Date.now()}`, {
140 + method: 'HEAD',
141 + cache: 'no-store',
142 + }).catch(() => null);
143 + const length = Number(response?.headers?.get('content-length'));
144 + // A turn that cancels every check would otherwise record nothing.
145 + if (length) observed.set(url, length);
146 + if (!live) return;
147 + // Only a change we watched may reload; a remount would reload forever.
148 + if (length && length !== originalSize)
149 + return setState({
150 + ...IDLE,
151 + size: length,
152 + replaced: seenOriginal,
153 + checked: true,
154 + });
155 + seenOriginal = seenOriginal || !!length;
156 +
157 + // The run answered at queue time, so only this reports a later failure.
158 + const status = await ask(STATUS_ABILITY, JSON.parse(mediaKey));
159 + if (!live) return;
160 + if (status?.status === 'error') {
161 + const reason = status.error_message || (await failureReason());
162 + if (!live) return;
163 + return setState({ ...IDLE, checked: true, failure: reason || '' });
164 + }
165 + if (status?.status === 'success' && status.optimized_size)
166 + return setState({
167 + ...IDLE,
168 + size: status.optimized_size,
169 + replaced: seenOriginal,
170 + checked: true,
171 + });
172 +
173 + setState((current) =>
174 + current.checked ? current : { ...current, checked: true },
175 + );
176 + // A hidden length leaves only the status, and it did not answer either.
177 + if (!length && status?.status !== 'unoptimized')
178 + return setUnwatchable(true);
179 + if (Date.now() - startedAt > GIVE_UP_MS) return setUnwatchable(true);
180 + timer = setTimeout(check, POLL_MS);
181 + };
182 + // A reload of a finished run would otherwise claim to be working first.
183 + check();
184 + return () => {
185 + live = false;
186 + clearTimeout(timer);
187 + };
188 + }, [url, originalSize, enabled, mediaKey]);
189 +
190 + return { ...state, unwatchable };
191 +};
192 +
193 +const savingText = (original, optimized) =>
194 + sprintf(
195 + // translators: %s is how much smaller the optimized image is, e.g. "42%".
196 + __('Image optimized — %s smaller.', 'extendify-local'),
197 + `${Math.round(((original - optimized) / original) * 100)}%`,
198 + );
199 +
200 +// Waiting on the lookup or on the decode would shift everything under it.
201 +const Thumbnail = ({ url, optimizing }) => (
202 + <div className="h-24 w-24 overflow-hidden rounded-lg bg-gray-100">
203 + {url ? (
204 + <img
205 + src={url}
206 + alt={__('The image being optimized', 'extendify-local')}
207 + className={`m-0 h-full w-full object-cover transition-[filter] duration-500 ${optimizing ? 'blur-sm' : 'blur-none'}`}
208 + />
209 + ) : null}
210 + </div>
47 211 );
48 212
213 +const Status = ({ pending, failed, children }) => (
214 + <p
215 + className={`m-0 p-0 text-start text-sm ${failed ? 'text-wp-alert-red' : 'text-gray-900'}`}
216 + >
217 + {pending ? <span className="status-animation">{children}</span> : children}
218 + </p>
219 +);
220 +
49 221 const Cost = ({ preview }) => {
50 222 const count = preview?.impact?.count ?? 1;
51 223 const quota = preview?.quota_remaining;
52 224
@@ -51,9 +223,9 @@
51 223 const quota = preview?.quota_remaining;
52 224
53 225 return (
54 226 <>
55 - <Line>
227 + <Status>
56 228 {sprintf(
57 229 // translators: %d is how many Imagify credits optimizing this image will use.
58 230 _n(
59 231 'Optimizing this image uses %d Imagify credit.',
@@ -62,11 +234,11 @@
62 234 'extendify-local',
63 235 ),
64 236 count,
65 237 )}
66 - </Line>
238 + </Status>
67 239 {quota == null ? null : (
68 - <p className="m-0 mt-1 p-0 text-sm text-gray-700">
240 + <p className="m-0 mt-1 p-0 text-start text-sm text-gray-700">
69 241 {sprintf(
70 242 // translators: %s is the percentage of the user's Imagify quota that is still unused, e.g. "88%".
71 243 __('%s of your quota remains.', 'extendify-local'),
72 244 `${Math.round(quota)}%`,
@@ -76,40 +248,80 @@
76 248 </>
77 249 );
78 250 };
79 251
80 -const Body = ({ gate, preview, blocked, result }) => {
81 - if (result) {
82 - const failed = result.error || result[ABILITY]?.status === 'error';
83 - return (
84 - <Line>
85 - {failed
86 - ? __("The image couldn't be optimized.", 'extendify-local')
87 - : __('Image optimized.', 'extendify-local')}
88 - </Line>
89 - );
90 - }
91 - if (!gate) return <Line>{__('Optimizing image...', 'extendify-local')}</Line>;
92 - if (!preview)
93 - return (
94 - <Line>{__('Checking your Imagify quota...', 'extendify-local')}</Line>
95 - );
96 - if (blocked) return <Line>{blocked}</Line>;
97 - return <Cost preview={preview} />;
98 -};
99 -
100 -export const OptimizeMedia = ({ inputs, result, onConfirm, onCancel }) => {
252 +export const OptimizeMedia = ({
253 + inputs,
254 + result,
255 + onConfirm,
256 + onCancel,
257 + live,
258 +}) => {
101 259 const gate = !!onConfirm && !result;
102 260 const preview = usePreview(inputs, gate);
103 261 const blocked = blockedMessage(preview?.status);
262 + const url = useImageUrl(inputs);
104 263
264 + const run = result?.[ABILITY] ?? {};
265 + const refused = result ? blockedMessage(run.status) : null;
266 + const failed = !!result && (result.error || run.status !== 'success');
267 + // A reoptimize answers with its own figures, so it has nothing left to watch.
268 + const queued = !!result && !refused && !failed && !run.optimized_size;
269 + const { size, replaced, unwatchable, checked, failure } = useOptimizedSize(
270 + url,
271 + run.original_size,
272 + queued && live,
273 + JSON.stringify(mediaInput(inputs)),
274 + );
275 +
276 + useEffect(() => {
277 + if (replaced) doReload();
278 + }, [replaced]);
279 +
280 + const watching =
281 + queued && live && checked && !unwatchable && !size && failure === null;
282 + const checking = queued && live && !checked && !unwatchable;
283 + const optimizing = watching || (!result && !gate);
284 + const pending = optimizing || checking || (gate && !preview);
285 +
286 + const wrong = !!refused || failed || failure !== null;
287 +
288 + const statusText = () => {
289 + if (refused) return refused;
290 + if (failed || failure !== null)
291 + return (
292 + failure || __("The image couldn't be optimized.", 'extendify-local')
293 + );
294 + if (run.optimized_size)
295 + return savingText(run.original_size, run.optimized_size);
296 + if (size) return savingText(run.original_size, size);
297 + if (!result)
298 + return gate
299 + ? preview
300 + ? blocked
301 + : __('Checking your Imagify quota...', 'extendify-local')
302 + : __('Optimizing image...', 'extendify-local');
303 + if (watching) return __('Optimizing image...', 'extendify-local');
304 + // The run may already be finished, so this must not say it is optimizing.
305 + if (checking) return __('Checking status...', 'extendify-local');
306 + return __(
307 + 'Imagify is optimizing this image in the background.',
308 + 'extendify-local',
309 + );
310 + };
311 + const message = statusText();
312 +
105 313 return (
106 - <div className="mb-4 ms-12 me-2 flex flex-col rounded-lg border border-gray-300 bg-gray-50">
107 - <div className="rounded-lg border-b border-gray-300 bg-white p-3">
108 - <Body gate={gate} preview={preview} blocked={blocked} result={result} />
109 - </div>
314 + <div className="mb-4 ms-2 me-2 flex flex-col gap-2">
315 + <Thumbnail url={url} optimizing={optimizing} />
316 + {message ? (
317 + <Status pending={pending} failed={wrong}>
318 + {message}
319 + </Status>
320 + ) : null}
321 + {gate && preview && !blocked ? <Cost preview={preview} /> : null}
110 322 {gate && preview ? (
111 - <div className="flex justify-start gap-2 p-3">
323 + <div className="flex justify-start gap-2">
112 324 <button
113 325 type="button"
114 326 className="w-full rounded-sm border border-gray-500 bg-white p-2 text-sm text-gray-900"
115 327 onClick={onCancel}
@@ -119,9 +331,14 @@
119 331 {blocked ? null : (
120 332 <button
121 333 type="button"
122 334 className="w-full rounded-sm border border-design-main bg-design-main p-2 text-sm text-white"
123 - onClick={() => onConfirm({ data: { ...inputs, confirm: true } })}
335 + onClick={() =>
336 + onConfirm({
337 + data: { ...inputs, confirm: true },
338 + shouldRefreshPage: false,
339 + })
340 + }
124 341 >
125 342 {__('Optimize image', 'extendify-local')}
126 343 </button>
127 344 )}