mirror of
				https://github.com/superseriousbusiness/gotosocial.git
				synced 2025-10-30 23:22:26 -05:00 
			
		
		
		
	[bugfix] Reset emoji fields on upload error (#2905)
This commit is contained in:
		
					parent
					
						
							
								f24ce34c3a
							
						
					
				
			
			
				commit
				
					
						578a4e0cf5
					
				
			
		
					 6 changed files with 206 additions and 122 deletions
				
			
		|  | @ -17,7 +17,9 @@ | |||
| 	along with this program.  If not, see <http://www.gnu.org/licenses/>.
 | ||||
| */ | ||||
| 
 | ||||
| import React from "react"; | ||||
| import { SerializedError } from "@reduxjs/toolkit"; | ||||
| import { FetchBaseQueryError } from "@reduxjs/toolkit/query"; | ||||
| import React, { ReactNode } from "react"; | ||||
| 
 | ||||
| function ErrorFallback({ error, resetErrorBoundary }) { | ||||
| 	return ( | ||||
|  | @ -44,39 +46,70 @@ function ErrorFallback({ error, resetErrorBoundary }) { | |||
| 	); | ||||
| } | ||||
| 
 | ||||
| function Error({ error }) { | ||||
| 	/* eslint-disable-next-line no-console */ | ||||
| 	console.error("Rendering error:", error); | ||||
| 	let message; | ||||
| interface GtsError { | ||||
| 	/** | ||||
| 	 * Error message returned from the API. | ||||
| 	 */ | ||||
| 	error: string; | ||||
| 
 | ||||
| 	if (error.data != undefined) { // RTK Query error with data
 | ||||
| 		if (error.status) { | ||||
| 			message = (<> | ||||
| 				<b>{error.status}:</b> {error.data.error} | ||||
| 				{error.data.error_description && | ||||
| 					<p> | ||||
| 						{error.data.error_description} | ||||
| 					</p> | ||||
| 				} | ||||
| 			</>); | ||||
| 		} else { | ||||
| 			message = error.data.error; | ||||
| 		} | ||||
| 	} else if (error.name != undefined || error.type != undefined) { // JS error
 | ||||
| 		message = (<> | ||||
| 			<b>{error.type && error.name}:</b> {error.message} | ||||
| 		</>); | ||||
| 	} else if (error.status && typeof error.error == "string") { | ||||
| 		message = (<> | ||||
| 			<b>{error.status}:</b> {error.error} | ||||
| 		</>); | ||||
| 	/** | ||||
| 	 * For OAuth errors: description of the error. | ||||
| 	 */ | ||||
| 	error_description?: string; | ||||
| } | ||||
| 
 | ||||
| interface ErrorProps { | ||||
| 	error: FetchBaseQueryError | SerializedError | Error | undefined; | ||||
| 	 | ||||
| 	/** | ||||
| 	 * Optional function to clear the error. | ||||
| 	 * If provided, rendered error will have | ||||
| 	 * a "dismiss" button. | ||||
| 	 */ | ||||
| 	reset?: () => void; | ||||
| } | ||||
| 
 | ||||
| function Error({ error, reset }: ErrorProps) { | ||||
| 	if (error === undefined) { | ||||
| 		return null; | ||||
| 	} | ||||
| 	 | ||||
| 	/* eslint-disable-next-line no-console */ | ||||
| 	console.error("caught error: ", error); | ||||
| 	 | ||||
| 	let message: ReactNode; | ||||
| 	if ("status" in error) { | ||||
| 		// RTK Query error with data.
 | ||||
| 		const gtsError = error.data as GtsError; | ||||
| 		const errMsg = gtsError.error_description ?? gtsError.error; | ||||
| 		message = <>Code {error.status} {errMsg}</>; | ||||
| 	} else { | ||||
| 		message = error.message ?? error; | ||||
| 		// SerializedError or Error.
 | ||||
| 		const errMsg = error.message ?? JSON.stringify(error); | ||||
| 		message = ( | ||||
| 			<>{error.name && `${error.name}: `}{errMsg}</> | ||||
| 		); | ||||
| 	} | ||||
| 
 | ||||
| 	let className = "error"; | ||||
| 	if (reset) { | ||||
| 		className += " with-dismiss"; | ||||
| 	} | ||||
| 
 | ||||
| 	return ( | ||||
| 		<div className="error"> | ||||
| 			{message} | ||||
| 		<div className={className}> | ||||
| 			<span>{message}</span> | ||||
| 			{ reset &&  | ||||
| 				<span  | ||||
| 					className="dismiss" | ||||
| 					onClick={reset} | ||||
| 					role="button" | ||||
| 					tabIndex={0} | ||||
| 				> | ||||
| 					<span>Dismiss</span> | ||||
| 					<i className="fa fa-fw fa-close" aria-hidden="true" /> | ||||
| 				</span> | ||||
| 			} | ||||
| 		</div> | ||||
| 	); | ||||
| } | ||||
|  |  | |||
|  | @ -29,6 +29,7 @@ import type { | |||
| 	RadioFormInputHook, | ||||
| 	TextFormInputHook, | ||||
| } from "../../lib/form/types"; | ||||
| import { nanoid } from "nanoid"; | ||||
| 
 | ||||
| export interface TextInputProps extends React.DetailedHTMLProps< | ||||
| 	React.InputHTMLAttributes<HTMLInputElement>, | ||||
|  | @ -92,22 +93,25 @@ export interface FileInputProps extends React.DetailedHTMLProps< | |||
| 
 | ||||
| export function FileInput({ label, field, ...props }: FileInputProps) { | ||||
| 	const { onChange, ref, infoComponent } = field; | ||||
| 	const id = nanoid(); | ||||
| 
 | ||||
| 	return ( | ||||
| 		<div className="form-field file"> | ||||
| 			<label> | ||||
| 				<div className="label">{label}</div> | ||||
| 				<div className="file-input button">Browse</div> | ||||
| 				{infoComponent} | ||||
| 				{/* <a onClick={removeFile("header")}>remove</a> */} | ||||
| 				<input | ||||
| 					type="file" | ||||
| 					className="hidden" | ||||
| 					onChange={onChange} | ||||
| 					ref={ref ? ref as RefObject<HTMLInputElement> : undefined} | ||||
| 					{...props} | ||||
| 				/> | ||||
| 			<label className="label-label" htmlFor={id}> | ||||
| 				{label} | ||||
| 			</label> | ||||
| 			<label className="label-button" htmlFor={id}> | ||||
| 				<div className="file-input button">Browse</div> | ||||
| 			</label> | ||||
| 			<input | ||||
| 				id={id} | ||||
| 				type="file" | ||||
| 				className="hidden" | ||||
| 				onChange={onChange} | ||||
| 				ref={ref ? ref as RefObject<HTMLInputElement> : undefined} | ||||
| 				{...props} | ||||
| 			/> | ||||
| 			{infoComponent} | ||||
| 		</div> | ||||
| 	); | ||||
| } | ||||
|  |  | |||
|  | @ -51,9 +51,9 @@ export default function MutationButton({ | |||
| 	} | ||||
| 
 | ||||
| 	return ( | ||||
| 		<div className={wrapperClassName}> | ||||
| 		<div className={wrapperClassName ? wrapperClassName : "mutation-button"}> | ||||
| 			{(showError && targetsThisButton && result.error) && | ||||
| 				<Error error={result.error} /> | ||||
| 				<Error error={result.error} reset={result.reset} /> | ||||
| 			} | ||||
| 			<button | ||||
| 				type="submit" | ||||
|  |  | |||
|  | @ -27,6 +27,7 @@ import type { | |||
| 	HookOpts, | ||||
| 	FileFormInputHook, | ||||
| } from "./types"; | ||||
| import { Error as ErrorC } from "../../components/error"; | ||||
| 
 | ||||
| const _default = undefined; | ||||
| export default function useFileInput( | ||||
|  | @ -41,6 +42,15 @@ export default function useFileInput( | |||
| 	const [imageURL, setImageURL] = useState<string>(); | ||||
| 	const [info, setInfo] = useState<React.JSX.Element>(); | ||||
| 
 | ||||
| 	function reset() { | ||||
| 		if (imageURL) { | ||||
| 			URL.revokeObjectURL(imageURL); | ||||
| 		} | ||||
| 		setImageURL(undefined); | ||||
| 		setFile(undefined); | ||||
| 		setInfo(undefined); | ||||
| 	} | ||||
| 
 | ||||
| 	function onChange(e: React.ChangeEvent<HTMLInputElement>) { | ||||
| 		const files = e.target.files; | ||||
| 		if (!files) { | ||||
|  | @ -59,25 +69,18 @@ export default function useFileInput( | |||
| 			setImageURL(URL.createObjectURL(file)); | ||||
| 		} | ||||
| 
 | ||||
| 		let size = prettierBytes(file.size); | ||||
| 		const sizePrettier = prettierBytes(file.size); | ||||
| 		if (maxSize && file.size > maxSize) { | ||||
| 			size = <span className="error-text">{size}</span>; | ||||
| 			const maxSizePrettier = prettierBytes(maxSize); | ||||
| 			setInfo( | ||||
| 				<ErrorC | ||||
| 					error={new Error(`file size ${sizePrettier} is larger than max size ${maxSizePrettier}`)} | ||||
| 					reset={(reset)} | ||||
| 				/> | ||||
| 			); | ||||
| 		} else { | ||||
| 			setInfo(<>{file.name} ({sizePrettier})</>); | ||||
| 		} | ||||
| 
 | ||||
| 		setInfo( | ||||
| 			<> | ||||
| 				{file.name} ({size}) | ||||
| 			</> | ||||
| 		); | ||||
| 	} | ||||
| 
 | ||||
| 	function reset() { | ||||
| 		if (imageURL) { | ||||
| 			URL.revokeObjectURL(imageURL); | ||||
| 		} | ||||
| 		setImageURL(undefined); | ||||
| 		setFile(undefined); | ||||
| 		setInfo(undefined); | ||||
| 	} | ||||
| 
 | ||||
| 	const infoComponent = ( | ||||
|  |  | |||
|  | @ -257,33 +257,37 @@ input, select, textarea { | |||
| 		overflow: auto; | ||||
| 		margin: 0; | ||||
| 	} | ||||
| 
 | ||||
| 	&.with-dismiss { | ||||
| 		display: flex; | ||||
| 		gap: 1rem; | ||||
| 		justify-content: space-between; | ||||
| 		align-items: center; | ||||
| 		align-items: center; | ||||
| 		flex-wrap: wrap; | ||||
| 		align-items: center; | ||||
| 		flex-wrap: wrap; | ||||
| 
 | ||||
| 		.dismiss { | ||||
| 			display: flex; | ||||
| 			flex-shrink: 0; | ||||
| 			align-items: center; | ||||
| 			align-self: stretch; | ||||
| 			gap: 0.25rem; | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| .mutation-button { | ||||
| 	display: flex; | ||||
| 	flex-direction: column; | ||||
| 	gap: 1rem; | ||||
| } | ||||
| 
 | ||||
| .hidden { | ||||
| 	display: none; | ||||
| } | ||||
| 
 | ||||
| .messagebutton, .messagebutton > div { | ||||
| 	display: flex; | ||||
| 	align-items: center; | ||||
| 	flex-wrap: wrap; | ||||
| 
 | ||||
| 	div.padded { | ||||
| 		margin-left: 1rem; | ||||
| 	} | ||||
| 
 | ||||
| 	button, .button { | ||||
| 		white-space: nowrap; | ||||
| 		margin-right: 1rem; | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| .messagebutton > div { | ||||
| 	button, .button { | ||||
| 		margin-top: 1rem; | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| .notImplemented { | ||||
| 	border: 2px solid rgb(70, 79, 88); | ||||
| 	background: repeating-linear-gradient( | ||||
|  | @ -500,12 +504,29 @@ form { | |||
| 	font-weight: bold; | ||||
| } | ||||
| 
 | ||||
| .form-field.file label { | ||||
| .form-field.file { | ||||
| 	display: grid; | ||||
| 	grid-template-columns: auto 1fr; | ||||
| 	grid-template-rows: auto auto; | ||||
| 	grid-template-areas: | ||||
| 		"label-label  label-label" | ||||
| 		"label-button file-info" | ||||
| 	; | ||||
| 	 | ||||
| 	.label { | ||||
| 		grid-column: 1 / span 2; | ||||
| 	.label-label { | ||||
| 		grid-area: label-label; | ||||
| 	} | ||||
| 
 | ||||
| 	.label-button { | ||||
| 		grid-area: label-button; | ||||
| 	} | ||||
| 
 | ||||
| 	.form-info { | ||||
| 		grid-area: file-info; | ||||
| 		.error { | ||||
| 			padding: 0.1rem; | ||||
|   			line-height: 1.4rem; | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -17,7 +17,7 @@ | |||
| 	along with this program.  If not, see <http://www.gnu.org/licenses/>.
 | ||||
| */ | ||||
| 
 | ||||
| import React, { useMemo, useEffect } from "react"; | ||||
| import React, { useMemo, useEffect, ReactNode } from "react"; | ||||
| import { useFileInput, useComboBoxInput } from "../../../../lib/form"; | ||||
| import useShortcode from "./use-shortcode"; | ||||
| import useFormSubmit from "../../../../lib/form/submit"; | ||||
|  | @ -27,52 +27,74 @@ import FakeToot from "../../../../components/fake-toot"; | |||
| import MutationButton from "../../../../components/form/mutation-button"; | ||||
| import { useAddEmojiMutation } from "../../../../lib/query/admin/custom-emoji"; | ||||
| import { useInstanceV1Query } from "../../../../lib/query/gts-api"; | ||||
| import prettierBytes from "prettier-bytes"; | ||||
| 
 | ||||
| export default function NewEmojiForm() { | ||||
| 	const shortcode = useShortcode(); | ||||
| 
 | ||||
| 	const { data: instance } = useInstanceV1Query(); | ||||
| 	const emojiMaxSize = useMemo(() => { | ||||
| 		return instance?.configuration?.emojis?.emoji_size_limit ?? 50 * 1024; | ||||
| 	}, [instance]); | ||||
| 
 | ||||
| 	const image = useFileInput("image", { | ||||
| 		withPreview: true, | ||||
| 		maxSize: emojiMaxSize | ||||
| 	}); | ||||
| 	const prettierMaxSize = useMemo(() => { | ||||
| 		return prettierBytes(emojiMaxSize); | ||||
| 	}, [emojiMaxSize]); | ||||
| 
 | ||||
| 	const category = useComboBoxInput("category"); | ||||
| 	const form = { | ||||
| 		shortcode: useShortcode(), | ||||
| 		image: useFileInput("image", { | ||||
| 			withPreview: true, | ||||
| 			maxSize: emojiMaxSize | ||||
| 		}), | ||||
| 		category: useComboBoxInput("category"), | ||||
| 	}; | ||||
| 
 | ||||
| 	const [submitForm, result] = useFormSubmit({ | ||||
| 		shortcode, image, category | ||||
| 	}, useAddEmojiMutation()); | ||||
| 	const [submitForm, result] = useFormSubmit( | ||||
| 		form, | ||||
| 		useAddEmojiMutation(), | ||||
| 		{ | ||||
| 			changedOnly: false, | ||||
| 			// On submission, reset form values
 | ||||
| 			// no matter what the result was.
 | ||||
| 			onFinish: (_res) => { | ||||
| 				form.shortcode.reset(); | ||||
| 				form.image.reset(); | ||||
| 				form.category.reset(); | ||||
| 			} | ||||
| 		}, | ||||
| 	); | ||||
| 
 | ||||
| 	useEffect(() => { | ||||
| 		if (shortcode.value === undefined || shortcode.value.length == 0) { | ||||
| 			if (image.value != undefined) { | ||||
| 				let [name, _ext] = image.value.name.split("."); | ||||
| 				shortcode.setter(name); | ||||
| 			} | ||||
| 		// If shortcode has not been entered yet, but an image file
 | ||||
| 		// has been submitted, suggest a shortcode based on filename.
 | ||||
| 		if ( | ||||
| 			(form.shortcode.value === undefined || form.shortcode.value.length === 0) && | ||||
| 			form.image.value !== undefined | ||||
| 		) { | ||||
| 			let [name, _ext] = form.image.value.name.split("."); | ||||
| 			form.shortcode.setter(name); | ||||
| 		} | ||||
| 
 | ||||
| 		/* We explicitly don't want to have 'shortcode' as a dependency here | ||||
| 			 because we only want to change the shortcode to the filename if the field is empty | ||||
| 			 at the moment the file is selected, not some time after when the field is emptied | ||||
| 		*/ | ||||
| 		/* eslint-disable-next-line react-hooks/exhaustive-deps */ | ||||
| 	}, [image.value]); | ||||
| 		// We explicitly don't want to have 'shortcode' as a
 | ||||
| 		// dependency here because we only want to change the
 | ||||
| 		// shortcode to the filename if the field is empty at
 | ||||
| 		// the moment the file is selected, not some time after
 | ||||
| 		// when the field is emptied.
 | ||||
| 		//
 | ||||
| 		// eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
| 	}, [form.image.value]); | ||||
| 
 | ||||
| 	let emojiOrShortcode; | ||||
| 
 | ||||
| 	if (image.previewValue != undefined) { | ||||
| 		emojiOrShortcode = <img | ||||
| 			className="emoji" | ||||
| 			src={image.previewValue} | ||||
| 			title={`:${shortcode.value}:`} | ||||
| 			alt={shortcode.value} | ||||
| 		/>; | ||||
| 	} else if (shortcode.value !== undefined && shortcode.value.length > 0) { | ||||
| 		emojiOrShortcode = `:${shortcode.value}:`; | ||||
| 	let emojiOrShortcode: ReactNode; | ||||
| 	if (form.image.previewValue !== undefined) { | ||||
| 		emojiOrShortcode = ( | ||||
| 			<img | ||||
| 				className="emoji" | ||||
| 				src={form.image.previewValue} | ||||
| 				title={`:${form.shortcode.value}:`} | ||||
| 				alt={form.shortcode.value} | ||||
| 			/> | ||||
| 		); | ||||
| 	} else if (form.shortcode.value !== undefined && form.shortcode.value.length > 0) { | ||||
| 		emojiOrShortcode = `:${form.shortcode.value}:`; | ||||
| 	} else { | ||||
| 		emojiOrShortcode = `:your_emoji_here:`; | ||||
| 	} | ||||
|  | @ -87,22 +109,23 @@ export default function NewEmojiForm() { | |||
| 
 | ||||
| 			<form onSubmit={submitForm} className="form-flex"> | ||||
| 				<FileInput | ||||
| 					field={image} | ||||
| 					field={form.image} | ||||
| 					label={`Image file: png, gif, or static webp; max size ${prettierMaxSize}`} | ||||
| 					accept="image/png,image/gif,image/webp" | ||||
| 				/> | ||||
| 
 | ||||
| 				<TextInput | ||||
| 					field={shortcode} | ||||
| 					field={form.shortcode} | ||||
| 					label="Shortcode, must be unique among the instance's local emoji" | ||||
| 					{...{pattern: "^\\w{2,30}$"}} | ||||
| 				/> | ||||
| 
 | ||||
| 				<CategorySelect | ||||
| 					field={category} | ||||
| 					children={[]} | ||||
| 					field={form.category} | ||||
| 				/> | ||||
| 
 | ||||
| 				<MutationButton | ||||
| 					disabled={image.previewValue === undefined} | ||||
| 					disabled={form.image.previewValue === undefined || form.shortcode.value?.length === 0} | ||||
| 					label="Upload emoji" | ||||
| 					result={result} | ||||
| 				/> | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue