PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 16.1-beta.3
Jetpack – WP Security, Backup, Speed, & Growth v16.1-beta.3
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 9 months ago
view.js
462 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 if ( event.dataTransfer ) {
308 for ( const item of Array.from( event.dataTransfer.items ) ) {
309 if ( item.webkitGetAsEntry()?.isDirectory ) {
310 return;
311 }
312 addFileToContext( item.getAsFile() );
313 }
314 }
315 const context = getContext();
316 context.isDropping = false;
317 },
318
319 /**
320 * Handle drag over event.
321 *
322 * @param {DragEvent} event - The drag event object.
323 */
324 dragOver: event => {
325 const context = getContext();
326 context.isDropping = true;
327 event.preventDefault();
328 },
329
330 /**
331 * Handle drag leave event.
332 */
333 dragLeave: () => {
334 const context = getContext();
335 context.isDropping = false;
336 },
337
338 /**
339 * Make the endpoint request.
340 * This function is a generator so that we can use the withScope function.
341 * And the context gets passed to the onProgress and onReadyStateChange functions.
342 *
343 * @param {File} file - The file to upload.
344 * @param {string} clientFileId - The client file ID.
345 * @yield {Promise<string>} The upload token.
346 */
347 uploadFile: function* ( file, clientFileId ) {
348 const { endpoint, i18n } = getConfig( NAMESPACE );
349
350 const token = yield getUploadToken();
351
352 if ( ! token ) {
353 updateFileContext( { error: i18n.uploadFailed, hasError: true }, clientFileId );
354 return;
355 }
356
357 const xhr = new XMLHttpRequest();
358 const formData = new FormData();
359
360 // Create an AbortController for this upload
361 const abortController = new AbortController();
362 uploadControllers.set( clientFileId, abortController );
363
364 xhr.open( 'POST', endpoint, true );
365 xhr.upload.addEventListener( 'progress', withScope( onProgress.bind( this, clientFileId ) ) );
366 xhr.addEventListener(
367 'readystatechange',
368 withScope( onReadyStateChange.bind( this, clientFileId ) )
369 );
370
371 // Handle abort signal
372 abortController.signal.addEventListener( 'abort', () => {
373 xhr.abort();
374 } );
375
376 formData.append( 'file', file );
377 formData.append( 'token', token );
378 xhr.send( formData );
379 },
380
381 /**
382 * Reset the files in the context.
383 */
384 resetFiles: () => {
385 const context = getContext();
386 context.files = [];
387 },
388
389 /**
390 * Remove a file from the context and cancel its upload if in progress.
391 *
392 * @param {Event} event - The event object.
393 * @yield {Promise<string>} The upload token.
394 */
395 removeFile: function* ( event ) {
396 event.preventDefault();
397
398 const context = getContext();
399 const clientFileId = event.target.dataset.id;
400
401 // Cancel the upload if it's in progress
402 if ( uploadControllers.has( clientFileId ) ) {
403 const abortController = uploadControllers.get( clientFileId );
404 abortController.abort(); // Cancel the upload
405 uploadControllers.delete( clientFileId ); // Clean up the controller
406 }
407
408 const file = context.files.find( fileObject => fileObject.id === clientFileId );
409 if ( file && file.url ) {
410 // Remove the object URL to free up memory
411 const urlToRemove = file.url.substring( 4, file.url.length - 1 );
412 URL.revokeObjectURL( urlToRemove );
413 }
414
415 if ( file && file.file_id ) {
416 const { endpoint } = getConfig( NAMESPACE );
417 const token = yield getUploadToken();
418 if ( token ) {
419 const formData = new FormData();
420 formData.append( 'token', token );
421 formData.append( 'file_id', file.file_id );
422 fetch( `${ endpoint }/remove`, {
423 method: 'POST',
424 body: formData,
425 } );
426 }
427 }
428 // Remove the file from the context
429 context.files = context.files.filter( fileObject => fileObject.id !== clientFileId );
430 jetpackFormStore.actions.updateFieldValue(
431 context.fieldId,
432 state.hasFiles ? context.files : ''
433 );
434 },
435
436 removeFileKeydown: event => {
437 if ( event.keyCode === ENTER || event.keyCode === SPACE ) {
438 event.preventDefault();
439 actions.removeFile( event );
440 }
441 },
442 },
443
444 callbacks: {
445 focusElement: function () {
446 const { ref } = getElement();
447 setTimeout( () => {
448 ref.focus( { focusVisible: true } );
449 }, 100 );
450
451 return withScope( function () {
452 const dropzone = ref
453 .closest( '.jetpack-form-file-field__container' )
454 .querySelector( '.jetpack-form-file-field__dropzone-inner' );
455 setTimeout( () => {
456 dropzone.focus( { focusVisible: true } );
457 }, 100 );
458 } );
459 },
460 },
461 } );
462