diff options
| author | 2022-12-11 16:00:23 +0100 | |
|---|---|---|
| committer | 2022-12-11 16:00:23 +0100 | |
| commit | 4b8d7bd952dd97091d1baddeab10213e9c38cef3 (patch) | |
| tree | 368b80e82590ca85c031b9d720dc7dd7c4fbbb6b /web/source/settings/admin | |
| parent | [docs] Serve static assets with nginx (#1251) (diff) | |
| download | gotosocial-4b8d7bd952dd97091d1baddeab10213e9c38cef3.tar.xz | |
[frogend] Emoji copy "Steal this look" (#1222)
* split emoji into local and remote, allow looking up remote emoji by toot url
* optimize some/all filtering
* fix local emoji routes
* implement copy action
* shortcode validation, don't wipe form on error
* copy & disable PATCH
* remove local toot acceptance for testing
* unused import
* parse emoji from account and status, get web_url from status uri
* fix url parse
* submit button loading info
* actually send category
* code cleanup, distinguish between account and status responses
* use loading icons
* fix loading icon on federation page
* require Loading element
* remove unused require
* query explanation, small accessibility tweaks
Diffstat (limited to 'web/source/settings/admin')
| -rw-r--r-- | web/source/settings/admin/emoji/local/detail.js (renamed from web/source/settings/admin/emoji/detail.js) | 17 | ||||
| -rw-r--r-- | web/source/settings/admin/emoji/local/index.js (renamed from web/source/settings/admin/emoji/index.js) | 2 | ||||
| -rw-r--r-- | web/source/settings/admin/emoji/local/new-emoji.js (renamed from web/source/settings/admin/emoji/new-emoji.js) | 39 | ||||
| -rw-r--r-- | web/source/settings/admin/emoji/local/overview.js (renamed from web/source/settings/admin/emoji/overview.js) | 11 | ||||
| -rw-r--r-- | web/source/settings/admin/emoji/remote/index.js | 54 | ||||
| -rw-r--r-- | web/source/settings/admin/emoji/remote/parse-from-toot.js | 319 | ||||
| -rw-r--r-- | web/source/settings/admin/federation.js | 7 | 
7 files changed, 426 insertions, 23 deletions
| diff --git a/web/source/settings/admin/emoji/detail.js b/web/source/settings/admin/emoji/local/detail.js index 51e291448..179ee7c7c 100644 --- a/web/source/settings/admin/emoji/detail.js +++ b/web/source/settings/admin/emoji/local/detail.js @@ -22,13 +22,14 @@ const React = require("react");  const { useRoute, Link, Redirect } = require("wouter"); -const { CategorySelect } = require("./category-select"); -const { useComboBoxInput, useFileInput } = require("../../components/form"); +const { CategorySelect } = require("../category-select"); +const { useComboBoxInput, useFileInput } = require("../../../components/form"); -const query = require("../../lib/query"); -const FakeToot = require("../../components/fake-toot"); +const query = require("../../../lib/query"); +const FakeToot = require("../../../components/fake-toot"); +const Loading = require("../../../components/loading"); -const base = "/settings/admin/custom-emoji"; +const base = "/settings/custom-emoji/local";  module.exports = function EmojiDetailRoute() {  	let [_match, params] = useRoute(`${base}/:emojiId`); @@ -54,7 +55,11 @@ function EmojiDetailData({emojiId}) {  			</div>  		);  	} else if (isLoading) { -		return "Loading..."; +		return ( +			<div> +				<Loading/> +			</div> +		);  	} else {  		return <EmojiDetail emoji={emoji}/>;  	} diff --git a/web/source/settings/admin/emoji/index.js b/web/source/settings/admin/emoji/local/index.js index 0fcda8264..1ccdece72 100644 --- a/web/source/settings/admin/emoji/index.js +++ b/web/source/settings/admin/emoji/local/index.js @@ -24,7 +24,7 @@ const {Switch, Route} = require("wouter");  const EmojiOverview = require("./overview");  const EmojiDetail = require("./detail"); -const base = "/settings/admin/custom-emoji"; +const base = "/settings/custom-emoji/local";  module.exports = function CustomEmoji() {  	return ( diff --git a/web/source/settings/admin/emoji/new-emoji.js b/web/source/settings/admin/emoji/local/new-emoji.js index 8cd604c02..985be2d32 100644 --- a/web/source/settings/admin/emoji/new-emoji.js +++ b/web/source/settings/admin/emoji/local/new-emoji.js @@ -21,17 +21,19 @@  const Promise = require('bluebird');  const React = require("react"); -const FakeToot = require("../../components/fake-toot"); -const MutateButton = require("../../components/mutation-button"); +const FakeToot = require("../../../components/fake-toot"); +const MutateButton = require("../../../components/mutation-button");  const {  	useTextInput,  	useFileInput,  	useComboBoxInput -} = require("../../components/form"); +} = require("../../../components/form"); -const query = require("../../lib/query"); -const { CategorySelect } = require('./category-select'); +const query = require("../../../lib/query"); +const { CategorySelect } = require('../category-select'); + +const shortcodeRegex = /^[a-z0-9_]+$/;  module.exports = function NewEmojiForm({ emoji }) {  	const emojiCodes = React.useMemo(() => { @@ -47,9 +49,26 @@ module.exports = function NewEmojiForm({ emoji }) {  	const [onShortcodeChange, resetShortcode, { shortcode, setShortcode, shortcodeRef }] = useTextInput("shortcode", {  		validator: function validateShortcode(code) { -			return emojiCodes.has(code) -				? "Shortcode already in use" -				: ""; +			// technically invalid, but hacky fix to prevent validation error on page load +			if (shortcode == "") {return "";} + +			if (emojiCodes.has(code)) { +				return "Shortcode already in use"; +			} + +			if (code.length < 2 || code.length > 30) { +				return "Shortcode must be between 2 and 30 characters"; +			} + +			if (code.toLowerCase() != code) { +				return "Shortcode must be lowercase"; +			} + +			if (!shortcodeRegex.test(code)) { +				return "Shortcode must only contain lowercase letters, numbers, and underscores"; +			} + +			return "";  		}  	}); @@ -78,11 +97,13 @@ module.exports = function NewEmojiForm({ emoji }) {  				image,  				shortcode,  				category -			}); +			}).unwrap();  		}).then(() => {  			resetFile();  			resetShortcode();  			resetCategory(); +		}).catch((e) => { +			console.error("Emoji upload error:", e);  		});  	} diff --git a/web/source/settings/admin/emoji/overview.js b/web/source/settings/admin/emoji/local/overview.js index b8ac87a0f..7a5cfaad6 100644 --- a/web/source/settings/admin/emoji/overview.js +++ b/web/source/settings/admin/emoji/local/overview.js @@ -23,10 +23,11 @@ const {Link} = require("wouter");  const NewEmojiForm = require("./new-emoji"); -const query = require("../../lib/query"); -const { useEmojiByCategory } = require("./category-select"); +const query = require("../../../lib/query"); +const { useEmojiByCategory } = require("../category-select"); +const Loading = require("../../../components/loading"); -const base = "/settings/admin/custom-emoji"; +const base = "/settings/custom-emoji/local";  module.exports = function EmojiOverview() {  	const { @@ -37,12 +38,12 @@ module.exports = function EmojiOverview() {  	return (  		<> -			<h1>Custom Emoji</h1> +			<h1>Custom Emoji (local)</h1>  			{error &&   				<div className="error accent">{error}</div>  			}  			{isLoading -				? "Loading..." +				? <Loading/>  				: <>  					<EmojiList emoji={emoji}/>  					<NewEmojiForm emoji={emoji}/> diff --git a/web/source/settings/admin/emoji/remote/index.js b/web/source/settings/admin/emoji/remote/index.js new file mode 100644 index 000000000..ae59673a5 --- /dev/null +++ b/web/source/settings/admin/emoji/remote/index.js @@ -0,0 +1,54 @@ +/* +	GoToSocial +	Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + +	This program is free software: you can redistribute it and/or modify +	it under the terms of the GNU Affero General Public License as published by +	the Free Software Foundation, either version 3 of the License, or +	(at your option) any later version. + +	This program is distributed in the hope that it will be useful, +	but WITHOUT ANY WARRANTY; without even the implied warranty of +	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +	GNU Affero General Public License for more details. + +	You should have received a copy of the GNU Affero General Public License +	along with this program.  If not, see <http://www.gnu.org/licenses/>. +*/ + +"use strict"; + +const React = require("react"); + +const ParseFromToot = require("./parse-from-toot"); + +const query = require("../../../lib/query"); +const Loading = require("../../../components/loading"); + +module.exports = function RemoteEmoji() { +	// local emoji are queried for shortcode collision detection +	const { +		data: emoji = [], +		isLoading, +		error +	} = query.useGetAllEmojiQuery({filter: "domain:local"}); + +	const emojiCodes = React.useMemo(() => { +		return new Set(emoji.map((e) => e.shortcode)); +	}, [emoji]); + +	return ( +		<> +			<h1>Custom Emoji (remote)</h1> +			{error &&  +				<div className="error accent">{error}</div> +			} +			{isLoading +				? <Loading/> +				: <> +					<ParseFromToot emoji={emoji} emojiCodes={emojiCodes} /> +				</> +			} +		</> +	); +};
\ No newline at end of file diff --git a/web/source/settings/admin/emoji/remote/parse-from-toot.js b/web/source/settings/admin/emoji/remote/parse-from-toot.js new file mode 100644 index 000000000..75ff8bf7a --- /dev/null +++ b/web/source/settings/admin/emoji/remote/parse-from-toot.js @@ -0,0 +1,319 @@ +/* +	GoToSocial +	Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + +	This program is free software: you can redistribute it and/or modify +	it under the terms of the GNU Affero General Public License as published by +	the Free Software Foundation, either version 3 of the License, or +	(at your option) any later version. + +	This program is distributed in the hope that it will be useful, +	but WITHOUT ANY WARRANTY; without even the implied warranty of +	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +	GNU Affero General Public License for more details. + +	You should have received a copy of the GNU Affero General Public License +	along with this program.  If not, see <http://www.gnu.org/licenses/>. +*/ + +"use strict"; + +const Promise = require("bluebird"); +const React = require("react"); +const Redux = require("react-redux"); +const syncpipe = require("syncpipe"); + +const { +	useTextInput, +	useComboBoxInput +} = require("../../../components/form"); + +const { CategorySelect } = require('../category-select'); + +const query = require("../../../lib/query"); +const Loading = require("../../../components/loading"); + +module.exports = function ParseFromToot({ emojiCodes }) { +	const [searchStatus, { data, isLoading, isSuccess, error }] = query.useSearchStatusForEmojiMutation(); +	const instanceDomain = Redux.useSelector((state) => (new URL(state.oauth.instance).host)); + +	const [onURLChange, _resetURL, { url }] = useTextInput("url"); + +	const searchResult = React.useMemo(() => { +		if (!isSuccess) { +			return null; +		} + +		if (data.type == "none") { +			return "No results found"; +		} + +		if (data.domain == instanceDomain) { +			return <b>This is a local user/toot, all referenced emoji are already on your instance</b>; +		} + +		if (data.list.length == 0) { +			return <b>This {data.type == "statuses" ? "toot" : "account"} doesn't use any custom emoji</b>; +		} + +		return ( +			<CopyEmojiForm +				localEmojiCodes={emojiCodes} +				type={data.type} +				domain={data.domain} +				emojiList={data.list} +			/> +		); +	}, [isSuccess, data, instanceDomain, emojiCodes]); + +	function submitSearch(e) { +		e.preventDefault(); +		searchStatus(url); +	} + +	return ( +		<div className="parse-emoji"> +			<h2>Steal this look</h2> +			<form onSubmit={submitSearch}> +				<div className="form-field text"> +					<label htmlFor="url"> +						Link to a toot: +					</label> +					<div className="row"> +						<input +							type="text" +							id="url" +							name="url" +							onChange={onURLChange} +							value={url} +						/> +						<button disabled={isLoading}> +							<i className={[ +								"fa", +								(isLoading +									? "fa-refresh fa-spin" +									: "fa-search") +							].join(" ")} aria-hidden="true" title="Search"/> +							<span className="sr-only">Search</span> +						</button> +					</div> +					{isLoading && <Loading/>} +					{error && <div className="error">{error.data.error}</div>} +				</div> +			</form> +			{searchResult} +		</div> +	); +}; + +function makeEmojiState(emojiList, checked) { +	/* Return a new object, with a key for every emoji's shortcode, +		 And a value for it's checkbox `checked` state. +	 */ +	return syncpipe(emojiList, [ +		(_) => _.map((emoji) => [emoji.shortcode, { +			checked, +			valid: true +		}]), +		(_) => Object.fromEntries(_) +	]); +} + +function updateEmojiState(emojiState, checked) { +	/* Create a new object with all emoji entries' checked state updated */ +	return syncpipe(emojiState, [ +		(_) => Object.entries(emojiState), +		(_) => _.map(([key, val]) => [key, { +			...val, +			checked +		}]), +		(_) => Object.fromEntries(_) +	]); +} + +function CopyEmojiForm({ localEmojiCodes, type, domain, emojiList }) { +	const [patchRemoteEmojis, patchResult] = query.usePatchRemoteEmojisMutation(); +	const [err, setError] = React.useState(); + +	const toggleAllRef = React.useRef(null); +	const [toggleAllState, setToggleAllState] = React.useState(0); +	const [emojiState, setEmojiState] = React.useState(makeEmojiState(emojiList, false)); +	const [someSelected, setSomeSelected] = React.useState(false); + +	const [categoryState, resetCategory, { category }] = useComboBoxInput("category"); + +	React.useEffect(() => { +		if (emojiList != undefined) { +			setEmojiState(makeEmojiState(emojiList, false)); +		} +	}, [emojiList]); + +	React.useEffect(() => { +		/* Updates (un)check all checkbox, based on shortcode checkboxes +			 Can be 0 (not checked), 1 (checked) or 2 (indeterminate) +		 */ +		if (toggleAllRef.current == null) { +			return; +		} + +		let values = Object.values(emojiState); +		/* one or more boxes are checked */ +		let some = values.some((v) => v.checked); + +		let all = false; +		if (some) { +			/* there's not at least one unchecked box */ +			all = !values.some((v) => v.checked == false); +		} + +		setSomeSelected(some); + +		if (some && !all) { +			setToggleAllState(2); +			toggleAllRef.current.indeterminate = true; +		} else { +			setToggleAllState(all ? 1 : 0); +			toggleAllRef.current.indeterminate = false; +		} +	}, [emojiState, toggleAllRef]); + +	function updateEmoji(shortcode, value) { +		setEmojiState({ +			...emojiState, +			[shortcode]: { +				...emojiState[shortcode], +				...value +			} +		}); +	} + +	function toggleAll(e) { +		let selectAll = e.target.checked; + +		if (toggleAllState == 2) { // indeterminate +			selectAll = false; +		} + +		setEmojiState(updateEmojiState(emojiState, selectAll)); +		setToggleAllState(selectAll); +	} + +	function submit(action) { +		Promise.try(() => { +			setError(null); +			const selectedShortcodes = syncpipe(emojiState, [ +				(_) => Object.entries(_), +				(_) => _.filter(([_shortcode, entry]) => entry.checked), +				(_) => _.map(([shortcode, entry]) => { +					if (action == "copy" && !entry.valid) { +						throw `One or more selected emoji have non-unique shortcodes (${shortcode}), unselect them or pick a different local shortcode`; +					} +					return { +						shortcode, +						localShortcode: entry.shortcode +					}; +				}) +			]); + +			return patchRemoteEmojis({ +				action, +				domain, +				list: selectedShortcodes, +				category +			}).unwrap(); +		}).then(() => { +			setEmojiState(makeEmojiState(emojiList, false)); +			resetCategory(); +		}).catch((e) => { +			if (Array.isArray(e)) { +				setError(e.map(([shortcode, msg]) => ( +					<div key={shortcode}> +						{shortcode}: <span style={{ fontWeight: "initial" }}>{msg}</span> +					</div> +				))); +			} else { +				setError(e); +			} +		}); +	} + +	return ( +		<div className="parsed"> +			<span>This {type == "statuses" ? "toot" : "account"} uses the following custom emoji, select the ones you want to copy/disable:</span> +			<div className="emoji-list"> +				<label className="header"> +					<input +						ref={toggleAllRef} +						type="checkbox" +						onChange={toggleAll} +						checked={toggleAllState === 1} +					/> All +				</label> +				{emojiList.map((emoji) => ( +					<EmojiEntry +						key={emoji.shortcode} +						emoji={emoji} +						localEmojiCodes={localEmojiCodes} +						updateEmoji={(value) => updateEmoji(emoji.shortcode, value)} +						checked={emojiState[emoji.shortcode].checked} +					/> +				))} +			</div> + +			<CategorySelect +				value={category} +				categoryState={categoryState} +			/> + +			<div className="action-buttons row"> +				<button disabled={!someSelected} onClick={() => submit("copy")}>{patchResult.isLoading ? "Processing..." : "Copy to local emoji"}</button> +				<button disabled={!someSelected} onClick={() => submit("disable")} className="danger">{patchResult.isLoading ? "Processing..." : "Disable"}</button> +			</div> +			{err && <div className="error"> +				{err} +			</div>} +			{patchResult.isSuccess && <div> +				Action applied to {patchResult.data.length} emoji +			</div>} +		</div> +	); +} + +function EmojiEntry({ emoji, localEmojiCodes, updateEmoji, checked }) { +	const [onShortcodeChange, _resetShortcode, { shortcode, shortcodeRef, shortcodeValid }] = useTextInput("shortcode", { +		defaultValue: emoji.shortcode, +		validator: function validateShortcode(code) { +			return (checked && localEmojiCodes.has(code)) +				? "Shortcode already in use" +				: ""; +		} +	}); + +	React.useEffect(() => { +		updateEmoji({ valid: shortcodeValid }); +		/* eslint-disable-next-line react-hooks/exhaustive-deps */ +	}, [shortcodeValid]); + +	return ( +		<label key={emoji.shortcode} className="row"> +			<input +				type="checkbox" +				onChange={(e) => updateEmoji({ checked: e.target.checked })} +				checked={checked} +			/> +			<img className="emoji" src={emoji.url} title={emoji.shortcode} /> + +			<input +				type="text" +				id="shortcode" +				name="Shortcode" +				ref={shortcodeRef} +				onChange={(e) => { +					onShortcodeChange(e); +					updateEmoji({ shortcode: e.target.value, checked: true }); +				}} +				value={shortcode} +			/> +		</label> +	); +}
\ No newline at end of file diff --git a/web/source/settings/admin/federation.js b/web/source/settings/admin/federation.js index 753024e58..f1023d365 100644 --- a/web/source/settings/admin/federation.js +++ b/web/source/settings/admin/federation.js @@ -30,6 +30,7 @@ const api = require("../lib/api");  const adminActions = require("../redux/reducers/admin").actions;  const submit = require("../lib/submit");  const BackButton = require("../components/back-button"); +const Loading = require("../components/loading");  const base = "/settings/admin/federation"; @@ -56,7 +57,9 @@ module.exports = function AdminSettings() {  		return (  			<div>  				<h1>Federation</h1> -				Loading... +				<div> +					<Loading/> +				</div>  			</div>  		);  	} @@ -321,7 +324,7 @@ function InstancePage({domain, Form}) {  	const [statusMsg, setStatus] = React.useState("");  	if (entry == undefined) { -		return "Loading..."; +		return <Loading/>;  	}  	const updateBlock = submit( | 
