← All changes
|
src/Agent/workflows/abilities/components/run/imagify/OptimizeMedia.jsx
+257
-40
3.2.1
→
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-2 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 | )} |