PluginProbe
Extendify / 3.2.3
Extendify v3.2.3
3.2.3 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 All 129 releases
← All changes | src/Agent/workflows/abilities/components/run/imagify/OptimizeMedia.jsx +261 -40 3.2.0 → 3.2.3 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,157 @@
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 + let seenOtherStatus = false;
138 + const startedAt = Date.now();
139 + const check = async () => {
140 + const response = await fetch(`${url}?extendify=${Date.now()}`, {
141 + method: 'HEAD',
142 + cache: 'no-store',
143 + }).catch(() => null);
144 + const length = Number(response?.headers?.get('content-length'));
145 + // A turn that cancels every check would otherwise record nothing.
146 + if (length) observed.set(url, length);
147 + if (!live) return;
148 + // Only a change we watched may reload; a remount would reload forever.
149 + if (length && length !== originalSize)
150 + return setState({
151 + ...IDLE,
152 + size: length,
153 + replaced: seenOriginal,
154 + checked: true,
155 + });
156 + seenOriginal = seenOriginal || !!length;
157 +
158 + // The run answered at queue time, so only this reports a later failure.
159 + const status = await ask(STATUS_ABILITY, JSON.parse(mediaKey));
160 + if (!live) return;
161 + const expired = Date.now() - startedAt > GIVE_UP_MS;
162 + // Imagify keeps an earlier failure until the queued job overwrites it.
163 + if (status?.status === 'error' && (seenOtherStatus || expired)) {
164 + const reason = status.error_message || (await failureReason());
165 + if (!live) return;
166 + return setState({ ...IDLE, checked: true, failure: reason || '' });
167 + }
168 + if (status?.status && status.status !== 'error') seenOtherStatus = true;
169 + if (status?.status === 'success' && status.optimized_size)
170 + return setState({
171 + ...IDLE,
172 + size: status.optimized_size,
173 + replaced: seenOriginal,
174 + checked: true,
175 + });
176 +
177 + setState((current) =>
178 + current.checked ? current : { ...current, checked: true },
179 + );
180 + // A hidden length leaves only the status, and it did not answer either.
181 + if (!length && status?.status !== 'unoptimized')
182 + return setUnwatchable(true);
183 + if (expired) return setUnwatchable(true);
184 + timer = setTimeout(check, POLL_MS);
185 + };
186 + // A reload of a finished run would otherwise claim to be working first.
187 + check();
188 + return () => {
189 + live = false;
190 + clearTimeout(timer);
191 + };
192 + }, [url, originalSize, enabled, mediaKey]);
193 +
194 + return { ...state, unwatchable };
195 +};
196 +
197 +const savingText = (original, optimized) =>
198 + sprintf(
199 + // translators: %s is how much smaller the optimized image is, e.g. "42%".
200 + __('Image optimized — %s smaller.', 'extendify-local'),
201 + `${Math.round(((original - optimized) / original) * 100)}%`,
202 + );
203 +
204 +// Waiting on the lookup or on the decode would shift everything under it.
205 +const Thumbnail = ({ url, optimizing }) => (
206 + <div className="h-24 w-24 overflow-hidden rounded-lg bg-gray-100">
207 + {url ? (
208 + <img
209 + src={url}
210 + alt={__('The image being optimized', 'extendify-local')}
211 + className={`m-0 h-full w-full object-cover transition-[filter] duration-500 ${optimizing ? 'blur-sm' : 'blur-none'}`}
212 + />
213 + ) : null}
214 + </div>
47 215 );
48 216
217 +const Status = ({ pending, failed, children }) => (
218 + <p
219 + className={`m-0 p-0 text-start text-sm ${failed ? 'text-wp-alert-red' : 'text-gray-900'}`}
220 + >
221 + {pending ? <span className="status-animation">{children}</span> : children}
222 + </p>
223 +);
224 +
49 225 const Cost = ({ preview }) => {
50 226 const count = preview?.impact?.count ?? 1;
51 227 const quota = preview?.quota_remaining;
52 228
@@ -51,9 +227,9 @@
51 227 const quota = preview?.quota_remaining;
52 228
53 229 return (
54 230 <>
55 - <Line>
231 + <Status>
56 232 {sprintf(
57 233 // translators: %d is how many Imagify credits optimizing this image will use.
58 234 _n(
59 235 'Optimizing this image uses %d Imagify credit.',
@@ -62,11 +238,11 @@
62 238 'extendify-local',
63 239 ),
64 240 count,
65 241 )}
66 - </Line>
242 + </Status>
67 243 {quota == null ? null : (
68 - <p className="m-0 mt-1 p-0 text-sm text-gray-700">
244 + <p className="m-0 mt-1 p-0 text-start text-sm text-gray-700">
69 245 {sprintf(
70 246 // translators: %s is the percentage of the user's Imagify quota that is still unused, e.g. "88%".
71 247 __('%s of your quota remains.', 'extendify-local'),
72 248 `${Math.round(quota)}%`,
@@ -76,40 +252,80 @@
76 252 </>
77 253 );
78 254 };
79 255
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 }) => {
256 +export const OptimizeMedia = ({
257 + inputs,
258 + result,
259 + onConfirm,
260 + onCancel,
261 + live,
262 +}) => {
101 263 const gate = !!onConfirm && !result;
102 264 const preview = usePreview(inputs, gate);
103 265 const blocked = blockedMessage(preview?.status);
266 + const url = useImageUrl(inputs);
104 267
268 + const run = result?.[ABILITY] ?? {};
269 + const refused = result ? blockedMessage(run.status) : null;
270 + const failed = !!result && (result.error || run.status !== 'success');
271 + // A reoptimize answers with its own figures, so it has nothing left to watch.
272 + const queued = !!result && !refused && !failed && !run.optimized_size;
273 + const { size, replaced, unwatchable, checked, failure } = useOptimizedSize(
274 + url,
275 + run.original_size,
276 + queued && live,
277 + JSON.stringify(mediaInput(inputs)),
278 + );
279 +
280 + useEffect(() => {
281 + if (replaced) doReload();
282 + }, [replaced]);
283 +
284 + const watching =
285 + queued && live && checked && !unwatchable && !size && failure === null;
286 + const checking = queued && live && !checked && !unwatchable;
287 + const optimizing = watching || (!result && !gate);
288 + const pending = optimizing || checking || (gate && !preview);
289 +
290 + const wrong = !!refused || failed || failure !== null;
291 +
292 + const statusText = () => {
293 + if (refused) return refused;
294 + if (failed || failure !== null)
295 + return (
296 + failure || __("The image couldn't be optimized.", 'extendify-local')
297 + );
298 + if (run.optimized_size)
299 + return savingText(run.original_size, run.optimized_size);
300 + if (size) return savingText(run.original_size, size);
301 + if (!result)
302 + return gate
303 + ? preview
304 + ? blocked
305 + : __('Checking your Imagify quota...', 'extendify-local')
306 + : __('Optimizing image...', 'extendify-local');
307 + if (watching) return __('Optimizing image...', 'extendify-local');
308 + // The run may already be finished, so this must not say it is optimizing.
309 + if (checking) return __('Checking status...', 'extendify-local');
310 + return __(
311 + 'Imagify is optimizing this image in the background.',
312 + 'extendify-local',
313 + );
314 + };
315 + const message = statusText();
316 +
105 317 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>
318 + <div className="mb-4 ms-2 me-2 flex flex-col gap-2">
319 + <Thumbnail url={url} optimizing={optimizing} />
320 + {message ? (
321 + <Status pending={pending} failed={wrong}>
322 + {message}
323 + </Status>
324 + ) : null}
325 + {gate && preview && !blocked ? <Cost preview={preview} /> : null}
110 326 {gate && preview ? (
111 - <div className="flex justify-start gap-2 p-3">
327 + <div className="flex justify-start gap-2">
112 328 <button
113 329 type="button"
114 330 className="w-full rounded-sm border border-gray-500 bg-white p-2 text-sm text-gray-900"
115 331 onClick={onCancel}
@@ -119,9 +335,14 @@
119 335 {blocked ? null : (
120 336 <button
121 337 type="button"
122 338 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 } })}
339 + onClick={() =>
340 + onConfirm({
341 + data: { ...inputs, confirm: true },
342 + shouldRefreshPage: false,
343 + })
344 + }
124 345 >
125 346 {__('Optimize image', 'extendify-local')}
126 347 </button>
127 348 )}