PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 16.2-a.1
Jetpack – WP Security, Backup, Speed, & Growth v16.2-a.1
16.2-a.3 16.1.2 16.2-a.1 16.1.1 16.1 16.1-beta 16.1-beta.2 16.1-beta.3 16.1-a.5 16.1-a.3 16.0.1 16.1-a.1 16.0 16.0-beta 16.0-a.7 16.0-a.5 15.9.1 16.0-a.3 16.0-a.1 15.9 15.9-beta 15.9-a.7 15.9-a.5 15.9-a.3 15.9-a.1 15.8 15.8-beta 15.8-a.7 15.8-a.5 5.2.5 5.3.4 5.4.4 5.5.5 5.6.5 5.7.5 5.8.4 5.9.4 6.0.4 6.1 6.1.1 6.1.2 6.1.3 6.1.4 6.1.5 6.2 6.2.1 6.2.2 6.2.3 6.2.4 6.2.5 6.3 6.3.1 6.3.2 6.3.3 6.3.4 6.3.5 6.3.6 6.3.7 6.4 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.5 6.5.1 6.5.2 6.5.3 6.5.4 6.6 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.7 6.7.1 6.7.2 6.7.3 6.7.4 6.8 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.9 6.9.1 6.9.2 6.9.3 6.9.4 7.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.1 7.1.1 7.1.2 7.1.3 7.1.4 7.1.5 7.2 7.2.1 7.2.1.1 7.2.2 7.2.3 7.2.4 7.2.5 7.3 7.3.0.1 7.3.1 7.3.1.1 7.3.2 7.3.3 7.3.4 7.3.5 7.4 7.4.1 7.4.2 7.4.3 7.4.4 7.4.5 7.5 7.5.0.1 7.5.1 7.5.2 7.5.3 7.5.4 7.5.5 7.5.6 7.5.7 7.6 7.6.1 7.6.2 7.6.3 7.6.4 7.7 7.7.1 7.7.2 7.7.3 7.7.4 7.7.5 7.7.6 7.8 7.8.1 7.8.2 7.8.3 7.8.4 7.9 7.9.1 7.9.2 7.9.3 7.9.4 8.0 8.0.1 8.0.2 8.0.3 8.1 8.1.1 8.1.2 8.1.3 8.1.4 8.2 8.2.0.1 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.2.6 8.3 8.3.1 8.3.2 8.3.3 8.4 8.4.1 8.4.2 8.4.3 8.4.4 8.4.5 8.5 8.5.1 8.5.2 8.5.3 8.6 8.6.1 8.6.2 8.6.3 8.6.4 8.7 8.7.0.1 8.7.1 8.7.2 8.7.3 8.7.4 8.8 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.9 8.9.1 8.9.2 8.9.3 8.9.4 9.0 9.0.1 9.0.2 9.0.3 9.0.4 9.0.5 9.1 9.1.1 9.1.2 9.1.3 9.2 9.2.1 9.2.2 9.2.3 9.2.4 9.3 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.4 9.4.1 9.4.2 9.4.3 9.4.4 9.5 9.5.1 9.5.2 9.5.3 9.5.4 9.5.5 9.6 9.6.1 9.6.2 9.6.3 9.6.4 9.7 9.7.1 9.7.2 15.7-beta.2 9.7.3 15.7.1 9.8 15.8-a.1 9.8.1 15.8-a.3 9.8.2 2.0.9 9.8.3 2.1.7 9.9 2.2.10 9.9.1 2.3.10 9.9.2 2.4.7 9.9.3 2.5.5 2.6.6 2.7.5 2.8.5 2.9.6 3.0.6 3.1.5 3.2.5 3.3.6 3.4.6 3.5.6 3.6.4 3.7.5 3.8.5 3.9.10 4.0.7 4.1.4 4.2.5 4.3.5 4.4.5 4.5.3 4.6.3 4.7.4 4.8.5 4.9.3 5.0.3 5.1.4 trunk 10.0 10.0.1 10.0.2 10.1 10.1.1 10.1.2 10.2 10.2.1 10.2.2 10.2.3 10.3 10.3.1 10.3.2 10.4 10.4.1 10.4.2 10.5 10.5.1 10.5.2 10.5.3 10.6 10.6.1 10.6.2 10.7 10.7.1 10.7.2 10.8 10.8.1 10.8.2 10.9 10.9.1 10.9.2 10.9.3 11.0 11.0.1 11.0.2 11.1 11.1.1 11.1.2 11.1.3 11.1.4 11.2 11.2.1 11.2.2 11.3 11.3.1 11.3.2 11.3.3 11.3.4 11.4 11.4.1 11.4.2 11.5 11.5.1 11.5.2 11.5.3 11.6 11.6.1 11.6.2 11.7 11.7.1 11.7.2 11.7.3 11.8 11.8.3 11.8.4 11.8.5 11.8.6 11.9 11.9.1 11.9.2 11.9.3 12.0 12.0.1 12.0.2 12.1 12.1.1 12.1.2 12.2 12.2.1 12.2.2 12.3 12.3.1 12.4 12.4.1 12.5 12.5.1 12.6 12.6.1 12.6.2 12.6.3 12.7 12.7.1 12.7.2 12.8 12.8.1 12.8.2 12.9 12.9.1 12.9.2 12.9.3 12.9.4 13.0 13.0.1 13.1 13.1.1 13.1.2 13.1.3 13.1.4 13.2 13.2.1 13.2.2 13.2.3 13.3 13.3.1 13.3.2 13.4 13.4.1 13.4.2 13.4.3 13.4.4 13.5 13.5.1 13.6 13.6.1 13.7 13.7.1 13.8 13.8.1 13.8.2 13.9 13.9.1 14.0 14.1 14.2 14.2.1 14.3 14.4 14.4.1 14.5 14.6 14.7 14.8 14.9 14.9.1 15.0 15.0.1 15.0.2 15.1 15.1.1 15.2 15.3 15.3.1 15.4 15.5 15.6 15.7 15.7-a.1 15.7-a.3 15.7-a.5 15.7-a.7 15.7-beta
jetpack / jetpack_vendor / automattic / jetpack-forms / src / modules / file-field / view.js
jetpack / jetpack_vendor / automattic / jetpack-forms / src / modules / file-field Last commit date
view.js 2 weeks ago
view.js
466 lines
1 /**
2 * WordPress dependencies
3 */
4 import { store, getContext, withScope, getElement, getConfig } from '@wordpress/interactivity';
5
6 const NAMESPACE = 'jetpack/field-file';
7
8 const ENTER = 13;
9 const SPACE = 32;
10
11 let uploadToken = null;
12 let tokenExpiry = null;
13
14 const jetpackFormStore = store( 'jetpack/form' );
15
16 /**
17 * Retuns the upload token. Sometimes it has to fetch a new one if it expired. Or we haven't needed one just yet.
18 *
19 * @return {string} The upload token.
20 */
21 const getUploadToken = async () => {
22 // Check if the token exists and is not expired
23 if ( uploadToken && tokenExpiry && Date.now() < tokenExpiry ) {
24 return uploadToken;
25 }
26
27 const { token, expiresAt } = await fetchUploadToken();
28 uploadToken = token;
29 tokenExpiry = expiresAt * 1000; // Convert expiry timestamp to milliseconds
30 return uploadToken;
31 };
32 /**
33 * Fetches the upload token from the server.
34 *
35 * @return {{ token: string, expiresAt: number }} The upload token and its expiration time.
36 */
37 const fetchUploadToken = async () => {
38 const { endpoint } = getConfig( NAMESPACE );
39
40 const tokenError = {
41 token: null, // Assuming the token is in the `token` field
42 expiresAt: 0,
43 };
44 try {
45 const response = await fetch( `${ endpoint }/token`, {
46 method: 'POST',
47 headers: {
48 'Content-Type': 'application/json',
49 },
50 body: JSON.stringify( { context: 'file-upload' } ),
51 } );
52
53 if ( ! response.ok ) {
54 return tokenError;
55 }
56
57 const data = await response.json();
58 return {
59 token: data.token, // Assuming the token is in the `token` field
60 expiresAt: data.expiration,
61 };
62 } catch ( error ) {
63 if ( error ) {
64 return tokenError;
65 }
66 }
67 return tokenError;
68 };
69
70 /**
71 * Format the file size to a human-readable string.
72 *
73 * @param {number} size - The size of the file in bytes.
74 * @param {number} [decimals=2] - The number of decimals to include.
75 *
76 * @return {string} The formatted file size.
77 */
78 const formatBytes = ( size, decimals = 2 ) => {
79 const config = getConfig( NAMESPACE );
80 if ( size === 0 ) return config.i18n.zeroBytes;
81 const k = 1024;
82 const dm = decimals < 0 ? 0 : decimals;
83 const sizes = config.i18n.fileSizeUnits || [ 'Bytes', 'KB', 'MB', 'GB', 'TB' ];
84 const i = Math.floor( Math.log( size ) / Math.log( k ) );
85 const formattedSize = parseFloat( ( size / Math.pow( k, i ) ).toFixed( dm ) );
86 const numberFormat = new Intl.NumberFormat( config.i18n.locale, {
87 minimumFractionDigits: dm,
88 maximumFractionDigits: dm,
89 } );
90 return `${ numberFormat.format( formattedSize ) } ${ sizes[ i ] }`;
91 };
92
93 const getFileIcon = file => {
94 const config = getConfig( NAMESPACE );
95 const fileType = file.type.split( '/' )[ 0 ];
96 const fileExtension = file.name.split( '.' ).pop().toLowerCase();
97
98 const iconMap = {
99 image: 'png',
100 video: 'mp4',
101 audio: 'mp3',
102 document: 'pdf',
103 application: 'txt',
104 };
105
106 const extensionMap = {
107 pdf: 'pdf',
108 doc: 'doc',
109 docx: 'doc',
110 txt: 'txt',
111 ppt: 'ppt',
112 pptx: 'ppt',
113 xls: 'xls',
114 xlsx: 'xls',
115 csv: 'xls',
116 zip: 'zip',
117 sql: 'sql',
118 cal: 'cal',
119 };
120 const iconName = extensionMap[ fileExtension ] || iconMap[ fileType ] || 'txt';
121 return 'url(' + config.iconsPath + iconName + '.svg)';
122 };
123
124 /**
125 * Add the file to the context.
126 *
127 * @param {File} file - The file to add.
128 */
129 const addFileToContext = file => {
130 const config = getConfig( NAMESPACE );
131 const context = getContext();
132
133 let error = null;
134
135 // Check that the file not more then the max size.
136 if ( file.size > config.maxUploadSize ) {
137 error = config.i18n.fileTooLarge;
138 }
139
140 // Check that the file type is allowed.
141 if ( ! context.allowedMimeTypes.includes( file.type ) ) {
142 error = config.i18n.invalidType;
143 }
144
145 // Get all files that don't have an error properly
146 const validFiles = context.files.filter( fileInfo => ! fileInfo.error );
147
148 // Check if the user is trying to add more files then allowed.
149 if ( context.maxFiles < validFiles.length + 1 ) {
150 error = config.i18n.maxFiles;
151 }
152
153 const clientFileId = performance.now() + '-' + Math.random();
154 const hasImage =
155 [ 'image/gif', 'image/jpg', 'image/png', 'image/jpeg' ].includes( file.type ) &&
156 URL.createObjectURL;
157 const fileUrl = hasImage ? 'url(' + URL.createObjectURL( file ) + ')' : getFileIcon( file );
158 context.files.push( {
159 name: file.name,
160 formattedSize: formatBytes( file.size, 2 ),
161 hasIcon: ! hasImage,
162 isUploaded: false,
163 hasError: !! error,
164 id: clientFileId,
165 url: hasImage ? fileUrl : null,
166 mask: ! hasImage ? fileUrl : null,
167 error,
168 } );
169
170 jetpackFormStore.actions.updateFieldValue( context.fieldId, context.files );
171
172 // Start the upload if we don't have any errors.
173 ! error && actions.uploadFile( file, clientFileId );
174
175 // Load the file so we can display it. In case it is an image.
176 };
177
178 // Map to store AbortControllers for each file upload
179 const uploadControllers = new Map();
180
181 /**
182 * Responsible for updating the progress circle.
183 * Gets called on the progress upload.
184 *
185 * @param {string} clientFileId - The client file ID.
186 * @param {ProgressEvent} event - The progress event object.
187 */
188 const onProgress = ( clientFileId, event ) => {
189 const progress = ( event.loaded / event.total ) * 100;
190 // We don't want to show 100% progress, as it's misleading.
191 updateFileContext( { progress: Math.min( progress, 97 ) }, clientFileId );
192 };
193
194 /**
195 * React to the onReadyStateChange event when the endpoint returns.
196 *
197 * @param {string} clientFileId - The file ID.
198 * @param {Event} event - The event object.
199 */
200 const onReadyStateChange = ( clientFileId, event ) => {
201 const xhr = event.target;
202 if ( xhr.readyState === 4 ) {
203 if ( xhr.status === 200 ) {
204 const response = JSON.parse( xhr.responseText );
205 if ( response.success ) {
206 updateFileContext(
207 {
208 file_id: response.data.file_id,
209 isUploaded: true,
210 name: response.data.name,
211 type: response.data.type,
212 size: response.data.size,
213 fileJson: JSON.stringify( {
214 file_id: response.data.file_id,
215 name: response.data.name,
216 size: response.data.size,
217 type: response.data.type,
218 } ),
219 },
220 clientFileId
221 );
222 return;
223 }
224 } else {
225 const config = getConfig( NAMESPACE );
226 updateFileContext( { error: config.i18n.uploadFailed, hasError: true }, clientFileId );
227 return;
228 }
229 if ( xhr.responseText ) {
230 const response = JSON.parse( xhr.responseText );
231 updateFileContext( { error: response.message, hasError: true }, clientFileId );
232 }
233 }
234 };
235
236 /**
237 * Update the context with the new updatedFile object based on the file ID.
238 *
239 * @param {object} updatedFile - The updated file object.
240 * @param {string} clientFileId - The client file ID.
241 */
242 const updateFileContext = ( updatedFile, clientFileId ) => {
243 const context = getContext();
244 const index = context.files.findIndex( file => file.id === clientFileId );
245 context.files[ index ] = Object.assign( context.files[ index ], updatedFile );
246
247 jetpackFormStore.actions.updateFieldValue( context.fieldId, context.files );
248 };
249
250 const { state, actions } = store( NAMESPACE, {
251 state: {
252 get isInlineForm() {
253 const { ref } = getElement();
254 const form = ref.closest( '.wp-block-jetpack-contact-form' );
255 return (
256 ( form && form.classList.contains( 'is-style-outlined' ) ) ||
257 form.classList.contains( 'is-style-animated' )
258 );
259 },
260 get hasFiles() {
261 return !! getContext().files.length > 0;
262 },
263
264 get hasMaxFiles() {
265 const context = getContext();
266 return context.maxFiles <= context.files.length;
267 },
268 },
269
270 actions: {
271 handleKeyDown: event => {
272 if ( event.keyCode === ENTER || event.keyCode === SPACE ) {
273 event.preventDefault();
274 actions.openFilePicker( event );
275 }
276 },
277 /**
278 * Open the file picker dialog.
279 */
280 openFilePicker() {
281 const { ref } = getElement();
282 const fileInput = ref.parentNode.querySelector( '.jetpack-form-file-field' );
283
284 if ( fileInput ) {
285 fileInput.value = ''; // Reset the field so that we always get the onchange event.
286 fileInput.click();
287 }
288 },
289
290 /**
291 * Handle file added event.
292 *
293 * @param {Event} event - The event object.
294 */
295 fileAdded( event ) {
296 const files = Array.from( event.target.files );
297 files.forEach( addFileToContext );
298 },
299
300 /**
301 * Handle file dropped event.
302 *
303 * @param {DragEvent} event - The drag event object.
304 */
305 fileDropped: event => {
306 event.preventDefault();
307 // A drop fires no focus event, so the form's `focusin` handler never sees it.
308 // Without this, dropping a file and submitting would report the fill as starting
309 // at the submit button rather than at the drop.
310 jetpackFormStore.actions.trackFirstInteraction();
311 if ( event.dataTransfer ) {
312 for ( const item of Array.from( event.dataTransfer.items ) ) {
313 if ( item.webkitGetAsEntry()?.isDirectory ) {
314 return;
315 }
316 addFileToContext( item.getAsFile() );
317 }
318 }
319 const context = getContext();
320 context.isDropping = false;
321 },
322
323 /**
324 * Handle drag over event.
325 *
326 * @param {DragEvent} event - The drag event object.
327 */
328 dragOver: event => {
329 const context = getContext();
330 context.isDropping = true;
331 event.preventDefault();
332 },
333
334 /**
335 * Handle drag leave event.
336 */
337 dragLeave: () => {
338 const context = getContext();
339 context.isDropping = false;
340 },
341
342 /**
343 * Make the endpoint request.
344 * This function is a generator so that we can use the withScope function.
345 * And the context gets passed to the onProgress and onReadyStateChange functions.
346 *
347 * @param {File} file - The file to upload.
348 * @param {string} clientFileId - The client file ID.
349 * @yield {Promise<string>} The upload token.
350 */
351 uploadFile: function* ( file, clientFileId ) {
352 const { endpoint, i18n } = getConfig( NAMESPACE );
353
354 const token = yield getUploadToken();
355
356 if ( ! token ) {
357 updateFileContext( { error: i18n.uploadFailed, hasError: true }, clientFileId );
358 return;
359 }
360
361 const xhr = new XMLHttpRequest();
362 const formData = new FormData();
363
364 // Create an AbortController for this upload
365 const abortController = new AbortController();
366 uploadControllers.set( clientFileId, abortController );
367
368 xhr.open( 'POST', endpoint, true );
369 xhr.upload.addEventListener( 'progress', withScope( onProgress.bind( this, clientFileId ) ) );
370 xhr.addEventListener(
371 'readystatechange',
372 withScope( onReadyStateChange.bind( this, clientFileId ) )
373 );
374
375 // Handle abort signal
376 abortController.signal.addEventListener( 'abort', () => {
377 xhr.abort();
378 } );
379
380 formData.append( 'file', file );
381 formData.append( 'token', token );
382 xhr.send( formData );
383 },
384
385 /**
386 * Reset the files in the context.
387 */
388 resetFiles: () => {
389 const context = getContext();
390 context.files = [];
391 },
392
393 /**
394 * Remove a file from the context and cancel its upload if in progress.
395 *
396 * @param {Event} event - The event object.
397 * @yield {Promise<string>} The upload token.
398 */
399 removeFile: function* ( event ) {
400 event.preventDefault();
401
402 const context = getContext();
403 const clientFileId = event.target.dataset.id;
404
405 // Cancel the upload if it's in progress
406 if ( uploadControllers.has( clientFileId ) ) {
407 const abortController = uploadControllers.get( clientFileId );
408 abortController.abort(); // Cancel the upload
409 uploadControllers.delete( clientFileId ); // Clean up the controller
410 }
411
412 const file = context.files.find( fileObject => fileObject.id === clientFileId );
413 if ( file && file.url ) {
414 // Remove the object URL to free up memory
415 const urlToRemove = file.url.substring( 4, file.url.length - 1 );
416 URL.revokeObjectURL( urlToRemove );
417 }
418
419 if ( file && file.file_id ) {
420 const { endpoint } = getConfig( NAMESPACE );
421 const token = yield getUploadToken();
422 if ( token ) {
423 const formData = new FormData();
424 formData.append( 'token', token );
425 formData.append( 'file_id', file.file_id );
426 fetch( `${ endpoint }/remove`, {
427 method: 'POST',
428 body: formData,
429 } );
430 }
431 }
432 // Remove the file from the context
433 context.files = context.files.filter( fileObject => fileObject.id !== clientFileId );
434 jetpackFormStore.actions.updateFieldValue(
435 context.fieldId,
436 state.hasFiles ? context.files : ''
437 );
438 },
439
440 removeFileKeydown: event => {
441 if ( event.keyCode === ENTER || event.keyCode === SPACE ) {
442 event.preventDefault();
443 actions.removeFile( event );
444 }
445 },
446 },
447
448 callbacks: {
449 focusElement: function () {
450 const { ref } = getElement();
451 setTimeout( () => {
452 ref.focus( { focusVisible: true } );
453 }, 100 );
454
455 return withScope( function () {
456 const dropzone = ref
457 .closest( '.jetpack-form-file-field__container' )
458 .querySelector( '.jetpack-form-file-field__dropzone-inner' );
459 setTimeout( () => {
460 dropzone.focus( { focusVisible: true } );
461 }, 100 );
462 } );
463 },
464 },
465 } );
466