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 | |
| 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')
| -rw-r--r-- | web/source/css/base.css | 10 | ||||
| -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 | ||||
| -rw-r--r-- | web/source/settings/components/form/text.jsx | 16 | ||||
| -rw-r--r-- | web/source/settings/components/loading.jsx | 27 | ||||
| -rw-r--r-- | web/source/settings/index.js | 9 | ||||
| -rw-r--r-- | web/source/settings/lib/query/custom-emoji.js | 97 | ||||
| -rw-r--r-- | web/source/settings/style.css | 48 | 
13 files changed, 623 insertions, 33 deletions
| diff --git a/web/source/css/base.css b/web/source/css/base.css index d2fa95a3b..73b533733 100644 --- a/web/source/css/base.css +++ b/web/source/css/base.css @@ -394,3 +394,13 @@ footer {  		color: $gray1;  	}  } + +label { +	cursor: pointer; +} + +@media (prefers-reduced-motion) { +	.fa-spin { +		animation: none; +	} +}
\ No newline at end of file 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( diff --git a/web/source/settings/components/form/text.jsx b/web/source/settings/components/form/text.jsx index a392239dc..c24628dd7 100644 --- a/web/source/settings/components/form/text.jsx +++ b/web/source/settings/components/form/text.jsx @@ -20,17 +20,14 @@  const React = require("react"); -module.exports = function useTextInput({name, Name}, {validator} = {}) { -	const [text, setText] = React.useState(""); +module.exports = function useTextInput({name, Name}, {validator, defaultValue=""} = {}) { +	const [text, setText] = React.useState(defaultValue); +	const [valid, setValid] = React.useState(true);  	const textRef = React.useRef(null);  	function onChange(e) {  		let input = e.target.value;  		setText(input); - -		if (validator) { -			validator(input); -		}  	}  	function reset() { @@ -39,7 +36,9 @@ module.exports = function useTextInput({name, Name}, {validator} = {}) {  	React.useEffect(() => {  		if (validator) { -			textRef.current.setCustomValidity(validator(text)); +			let res = validator(text); +			setValid(res == ""); +			textRef.current.setCustomValidity(res);  			textRef.current.reportValidity();  		}  	}, [text, textRef, validator]); @@ -50,7 +49,8 @@ module.exports = function useTextInput({name, Name}, {validator} = {}) {  		{  			[name]: text,  			[`${name}Ref`]: textRef, -			[`set${Name}`]: setText +			[`set${Name}`]: setText, +			[`${name}Valid`]: valid  		}  	];  };
\ No newline at end of file diff --git a/web/source/settings/components/loading.jsx b/web/source/settings/components/loading.jsx new file mode 100644 index 000000000..51ce3a18b --- /dev/null +++ b/web/source/settings/components/loading.jsx @@ -0,0 +1,27 @@ +/* +	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"); + +module.exports = function Loading() { +	return ( +		<i className="fa fa-spin fa-refresh" aria-label="Loading" title="Loading"/> +	); +};
\ No newline at end of file diff --git a/web/source/settings/index.js b/web/source/settings/index.js index 58afe5d28..b087f945c 100644 --- a/web/source/settings/index.js +++ b/web/source/settings/index.js @@ -32,6 +32,7 @@ const oauth = require("./redux/reducers/oauth").actions;  const { AuthenticationError } = require("./lib/errors");  const Login = require("./components/login"); +const Loading = require("./components/loading");  require("./style.css"); @@ -46,7 +47,11 @@ const nav = {  		"Instance Settings": require("./admin/settings.js"),  		"Actions": require("./admin/actions"),  		"Federation": require("./admin/federation.js"), -		"Custom Emoji": require("./admin/emoji"), +	}, +	"Custom Emoji": { +		adminOnly: true, +		"Local": require("./admin/emoji/local"), +		"Remote": require("./admin/emoji/remote"),  	}  }; @@ -167,7 +172,7 @@ function App() {  function Main() {  	return (  		<Provider store={store}> -			<PersistGate loading={"loading..."} persistor={persistor}> +			<PersistGate loading={<section><Loading/></section>} persistor={persistor}>  				<App />  			</PersistGate>  		</Provider> diff --git a/web/source/settings/lib/query/custom-emoji.js b/web/source/settings/lib/query/custom-emoji.js index fa2a08db6..593556620 100644 --- a/web/source/settings/lib/query/custom-emoji.js +++ b/web/source/settings/lib/query/custom-emoji.js @@ -18,8 +18,18 @@  "use strict"; +const Promise = require("bluebird"); +  const base = require("./base"); +function unwrap(res) { +	if (res.error != undefined) { +		throw res.error; +	} else { +		return res.data; +	} +} +  const endpoints = (build) => ({  	getAllEmoji: build.query({  		query: (params = {}) => ({ @@ -77,6 +87,93 @@ const endpoints = (build) => ({  			url: `/api/v1/admin/custom_emojis/${id}`  		}),  		invalidatesTags: (res, error, id) => [{type: "Emojis", id}] +	}), +	searchStatusForEmoji: build.mutation({ +		query: (url) => ({ +			method: "GET", +			url: `/api/v2/search?q=${encodeURIComponent(url)}&resolve=true&limit=1` +		}), +		transformResponse: (res) => { +			/* Parses search response, prioritizing a toot result, +			   and returns referenced custom emoji +			*/ +			let type; + +			if (res.statuses.length > 0) { +				type = "statuses"; +			} else if (res.accounts.length > 0) { +				type = "accounts"; +			} else { +				return { +					type: "none" +				}; +			} + +			let data = res[type][0]; + +			return { +				type, +				domain: (new URL(data.url)).host, // to get WEB_DOMAIN, see https://github.com/superseriousbusiness/gotosocial/issues/1225 +				list: data.emojis +			}; +		} +	}), +	patchRemoteEmojis: build.mutation({ +		queryFn: ({action, domain, list, category}, api, _extraOpts, baseQuery) => { +			const data = []; +			const errors = []; + +			return Promise.each(list, (emoji) => { +				return Promise.try(() => { +					return baseQuery({ +						method: "GET", +						url: `/api/v1/admin/custom_emojis`, +						params: { +							filter: `domain:${domain},shortcode:${emoji.shortcode}`, +							limit: 1 +						} +					}).then(unwrap); +				}).then(([lookup]) => { +					if (lookup == undefined) { throw "not found"; } + +					let body = { +						type: action +					}; + +					if (action == "copy") { +						body.shortcode = emoji.localShortcode ?? emoji.shortcode; +						if (category.trim().length != 0) { +							body.category = category; +						} +					} + +					return baseQuery({ +						method: "PATCH", +						url: `/api/v1/admin/custom_emojis/${lookup.id}`, +						asForm: true, +						body: body +					}).then(unwrap); +				}).then((res) => { +					data.push([emoji.shortcode, res]); +				}).catch((e) => { +					console.error("emoji lookup for", emoji.shortcode, "failed:", e); +					let msg = e.message ?? e; +					if (e.data.error) { +						msg = e.data.error; +					} +					errors.push([emoji.shortcode, msg]); +				}); +			}).then(() => { +				if (errors.length == 0) { +					return { data }; +				} else { +					return { +						error: errors +					}; +				} +			}); +		}, +		invalidatesTags: () => [{type: "Emojis", id: "LIST"}]  	})  }); diff --git a/web/source/settings/style.css b/web/source/settings/style.css index 7a71c3278..7ed2fb725 100644 --- a/web/source/settings/style.css +++ b/web/source/settings/style.css @@ -598,4 +598,52 @@ span.form-info {  .left-border {  	border-left: 0.2rem solid $border-accent;  	padding-left: 0.4rem; +} + +.parse-emoji { +	.parsed { +		margin-top: 0.5rem; +		display: flex; +		flex-direction: column; +		gap: 1rem; + +		& > span { +			margin-bottom: -1rem; +		} +		 +		.action-buttons { +			gap: 1rem; +		} + +		.emoji-list { +			display: flex; +			flex-direction: column; + +			& > * { +				gap: 1rem; +				align-items: center; +				padding: 0.5rem 1rem; +			} + +			.header { +				background: $gray2; +				display: flex; +			} + +			.row { +				display: grid; +				grid-template-columns: auto auto 1fr; + +				&:hover { +					background: $settings-entry-hover-bg; +				} +			} + +			.emoji { +				height: 2rem; +				width: 2rem; +				margin: 0; +			} +		} +	}  }
\ No newline at end of file | 
