diff options
Diffstat (limited to 'web/source/settings/admin/emoji')
-rw-r--r-- | web/source/settings/admin/emoji/category-select.jsx | 42 | ||||
-rw-r--r-- | web/source/settings/admin/emoji/local/detail.js | 167 | ||||
-rw-r--r-- | web/source/settings/admin/emoji/local/index.js | 16 | ||||
-rw-r--r-- | web/source/settings/admin/emoji/local/new-emoji.js | 146 | ||||
-rw-r--r-- | web/source/settings/admin/emoji/local/overview.js | 51 | ||||
-rw-r--r-- | web/source/settings/admin/emoji/local/use-shortcode.js | 61 | ||||
-rw-r--r-- | web/source/settings/admin/emoji/remote/index.js | 6 | ||||
-rw-r--r-- | web/source/settings/admin/emoji/remote/parse-from-toot.js | 322 |
8 files changed, 332 insertions, 479 deletions
diff --git a/web/source/settings/admin/emoji/category-select.jsx b/web/source/settings/admin/emoji/category-select.jsx index d22534ea8..a35b3f2e3 100644 --- a/web/source/settings/admin/emoji/category-select.jsx +++ b/web/source/settings/admin/emoji/category-select.jsx @@ -1,19 +1,19 @@ /* - GoToSocial - Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org + GoToSocial + Copyright (C) 2021-2023 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 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. + 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/>. + 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"; @@ -36,13 +36,15 @@ function useEmojiByCategory(emoji) { ), [emoji]); } -function CategorySelect({value, categoryState, setIsNew=() => {}, children}) { +function CategorySelect({ field, children }) { + const { value, setIsNew } = field; + const { data: emoji = [], isLoading, isSuccess, error - } = query.useGetAllEmojiQuery({filter: "domain:local"}); + } = query.useGetAllEmojiQuery({ filter: "domain:local" }); const emojiByCategory = useEmojiByCategory(emoji); @@ -52,7 +54,7 @@ function CategorySelect({value, categoryState, setIsNew=() => {}, children}) { const categoryItems = React.useMemo(() => { return syncpipe(emojiByCategory, [ (_) => Object.keys(_), // just emoji category names - (_) => matchSorter(_, value, {threshold: matchSorter.rankings.NO_MATCH}), // sorted by complex algorithm + (_) => matchSorter(_, value, { threshold: matchSorter.rankings.NO_MATCH }), // sorted by complex algorithm (_) => _.map((categoryName) => [ // map to input value, and selectable element with icon categoryName, <> @@ -67,24 +69,24 @@ function CategorySelect({value, categoryState, setIsNew=() => {}, children}) { if (value != undefined && isSuccess && value.trim().length > 0) { setIsNew(!categories.has(value.trim())); } - }, [categories, value, setIsNew, isSuccess]); + }, [categories, value, isSuccess, setIsNew]); if (error) { // fall back to plain text input, but this would almost certainly have caused a bigger error message elsewhere return ( <> - <input type="text" placeholder="e.g., reactions" onChange={(e) => {categoryState.value = e.target.value;}}/>; + <input type="text" placeholder="e.g., reactions" onChange={(e) => { field.value = e.target.value; }} />; </> ); } else if (isLoading) { - return <input type="text" value="Loading categories..." disabled={true}/>; + return <input type="text" value="Loading categories..." disabled={true} />; } return ( <ComboBox - state={categoryState} + field={field} items={categoryItems} label="Category" - placeHolder="e.g., reactions" + placeholder="e.g., reactions" children={children} /> ); diff --git a/web/source/settings/admin/emoji/local/detail.js b/web/source/settings/admin/emoji/local/detail.js index cc3ce6a70..cecd36869 100644 --- a/web/source/settings/admin/emoji/local/detail.js +++ b/web/source/settings/admin/emoji/local/detail.js @@ -19,155 +19,128 @@ "use strict"; const React = require("react"); - const { useRoute, Link, Redirect } = require("wouter"); +const query = require("../../../lib/query"); + +const { useComboBoxInput, useFileInput, useValue } = require("../../../lib/form"); const { CategorySelect } = require("../category-select"); -const { useComboBoxInput, useFileInput } = require("../../../components/form"); -const query = require("../../../lib/query"); +const useFormSubmit = require("../../../lib/form/submit"); + const FakeToot = require("../../../components/fake-toot"); +const FormWithData = require("../../../lib/form/form-with-data"); const Loading = require("../../../components/loading"); +const { FileInput } = require("../../../components/form/inputs"); +const MutationButton = require("../../../components/form/mutation-button"); +const { Error } = require("../../../components/error"); const base = "/settings/custom-emoji/local"; module.exports = function EmojiDetailRoute() { let [_match, params] = useRoute(`${base}/:emojiId`); if (params?.emojiId == undefined) { - return <Redirect to={base}/>; + return <Redirect to={base} />; } else { return ( <div className="emoji-detail"> <Link to={base}><a>< go back</a></Link> - <EmojiDetailData emojiId={params.emojiId}/> + <FormWithData dataQuery={query.useGetEmojiQuery} queryArg={params.emojiId} DataForm={EmojiDetailForm} /> </div> ); } }; -function EmojiDetailData({emojiId}) { - const {currentData: emoji, isLoading, error} = query.useGetEmojiQuery(emojiId); - - if (error) { - return ( - <div className="error accent"> - {error.status}: {error.data.error} - </div> - ); - } else if (isLoading) { - return ( - <div> - <Loading/> - </div> - ); - } else { - return <EmojiDetail emoji={emoji}/>; - } -} - -function EmojiDetail({emoji}) { - const [modifyEmoji, modifyResult] = query.useEditEmojiMutation(); +function EmojiDetailForm({ data: emoji }) { + const form = { + id: useValue("id", emoji.id), + category: useComboBoxInput("category", { defaultValue: emoji.category }), + image: useFileInput("image", { + withPreview: true, + maxSize: 50 * 1024 // TODO: get from instance api + }) + }; - const [isNewCategory, setIsNewCategory] = React.useState(false); + const [modifyEmoji, result] = useFormSubmit(form, query.useEditEmojiMutation()); - const [categoryState, _resetCategory, { category }] = useComboBoxInput("category", {defaultValue: emoji.category}); - - const [onFileChange, _resetFile, { image, imageURL, imageInfo }] = useFileInput("image", { - withPreview: true, - maxSize: 50 * 1024 - }); + // Automatic submitting of category change + React.useEffect(() => { + if ( + form.category.hasChanged() && + !form.category.state.open && + !form.category.isNew) { + modifyEmoji(); + } + /* eslint-disable-next-line react-hooks/exhaustive-deps */ + }, [form.category.hasChanged(), form.category.isNew, form.category.state.open]); - function modifyCategory() { - modifyEmoji({id: emoji.id, category: category.trim()}); - } + const [deleteEmoji, deleteResult] = query.useDeleteEmojiMutation(); - function modifyImage() { - modifyEmoji({id: emoji.id, image: image}); + if (deleteResult.isSuccess) { + return <Redirect to={base} />; } - React.useEffect(() => { - if (category != emoji.category && !categoryState.open && !isNewCategory && category.trim().length > 0) { - console.log("updating to", category); - modifyEmoji({id: emoji.id, category: category.trim()}); - } - }, [isNewCategory, category, categoryState.open, emoji.category, emoji.id, modifyEmoji]); - return ( <> <div className="emoji-header"> - <img src={emoji.url} alt={emoji.shortcode} title={emoji.shortcode}/> + <img src={emoji.url} alt={emoji.shortcode} title={emoji.shortcode} /> <div> <h2>{emoji.shortcode}</h2> - <DeleteButton id={emoji.id}/> + <MutationButton + label="Delete" + type="button" + onClick={() => deleteEmoji(emoji.id)} + className="danger" + showError={false} + result={deleteResult} + /> </div> </div> - <div className="left-border"> - <h2>Modify this emoji {modifyResult.isLoading && "(processing..)"}</h2> - - {modifyResult.error && <div className="error"> - {modifyResult.error.status}: {modifyResult.error.data.error} - </div>} + <form onSubmit={modifyEmoji} className="left-border"> + <h2>Modify this emoji {result.isLoading && <Loading />}</h2> <div className="update-category"> <CategorySelect - value={category} - categoryState={categoryState} - setIsNew={setIsNewCategory} + field={form.category} > - <button style={{visibility: (isNewCategory ? "initial" : "hidden")}} onClick={modifyCategory}> - Create - </button> + <MutationButton + name="create-category" + label="Create" + result={result} + showError={false} + style={{ visibility: (form.category.isNew ? "initial" : "hidden") }} + /> </CategorySelect> </div> <div className="update-image"> - <b>Image</b> - <div className="form-field file"> - <label className="file-input button" htmlFor="image"> - Browse - </label> - {imageInfo} - <input - className="hidden" - type="file" - id="image" - name="Image" - accept="image/png,image/gif" - onChange={onFileChange} - /> - </div> - - <button onClick={modifyImage} disabled={image == undefined}>Replace image</button> + <FileInput + field={form.image} + label="Image" + accept="image/png,image/gif" + /> + + <MutationButton + name="image" + label="Replace image" + showError={false} + result={result} + /> <FakeToot> Look at this new custom emoji <img className="emoji" - src={imageURL ?? emoji.url} + src={form.image.previewURL ?? emoji.url} title={`:${emoji.shortcode}:`} alt={emoji.shortcode} /> isn't it cool? </FakeToot> + + {result.error && <Error error={result.error} />} + {deleteResult.error && <Error error={deleteResult.error} />} </div> - </div> + </form> </> ); -} - -function DeleteButton({id}) { - // TODO: confirmation dialog? - const [deleteEmoji, deleteResult] = query.useDeleteEmojiMutation(); - - let text = "Delete"; - if (deleteResult.isLoading) { - text = "Deleting..."; - } - - if (deleteResult.isSuccess) { - return <Redirect to={base}/>; - } - - return ( - <button className="danger" onClick={() => deleteEmoji(id)} disabled={deleteResult.isLoading}>{text}</button> - ); }
\ No newline at end of file diff --git a/web/source/settings/admin/emoji/local/index.js b/web/source/settings/admin/emoji/local/index.js index 4160fe41d..68cbbc47f 100644 --- a/web/source/settings/admin/emoji/local/index.js +++ b/web/source/settings/admin/emoji/local/index.js @@ -19,7 +19,7 @@ "use strict"; const React = require("react"); -const {Switch, Route} = require("wouter"); +const { Switch, Route } = require("wouter"); const EmojiOverview = require("./overview"); const EmojiDetail = require("./detail"); @@ -28,13 +28,11 @@ const base = "/settings/custom-emoji/local"; module.exports = function CustomEmoji() { return ( - <> - <Switch> - <Route path={`${base}/:emojiId`}> - <EmojiDetail /> - </Route> - <EmojiOverview /> - </Switch> - </> + <Switch> + <Route path={`${base}/:emojiId`}> + <EmojiDetail baseUrl={base} /> + </Route> + <EmojiOverview baseUrl={base} /> + </Switch> ); }; diff --git a/web/source/settings/admin/emoji/local/new-emoji.js b/web/source/settings/admin/emoji/local/new-emoji.js index 6701dbf5a..cf1b5f774 100644 --- a/web/source/settings/admin/emoji/local/new-emoji.js +++ b/web/source/settings/admin/emoji/local/new-emoji.js @@ -18,101 +18,61 @@ "use strict"; -const Promise = require('bluebird'); const React = require("react"); -const FakeToot = require("../../../components/fake-toot"); -const MutateButton = require("../../../components/mutation-button"); +const query = require("../../../lib/query"); const { - useTextInput, useFileInput, useComboBoxInput -} = require("../../../components/form"); +} = require("../../../lib/form"); +const useShortcode = require("./use-shortcode"); -const query = require("../../../lib/query"); -const { CategorySelect } = require('../category-select'); +const useFormSubmit = require("../../../lib/form/submit"); -const shortcodeRegex = /^[a-z0-9_]+$/; +const { + TextInput, FileInput +} = require("../../../components/form/inputs"); -module.exports = function NewEmojiForm({ emoji }) { - const emojiCodes = React.useMemo(() => { - return new Set(emoji.map((e) => e.shortcode)); - }, [emoji]); +const { CategorySelect } = require('../category-select'); +const FakeToot = require("../../../components/fake-toot"); +const MutationButton = require("../../../components/form/mutation-button"); - const [addEmoji, result] = query.useAddEmojiMutation(); +module.exports = function NewEmojiForm() { + const shortcode = useShortcode(); - const [onFileChange, resetFile, { image, imageURL, imageInfo }] = useFileInput("image", { + const image = useFileInput("image", { withPreview: true, - maxSize: 50 * 1024 + maxSize: 50 * 1024 // TODO: get from instance api? }); - const [onShortcodeChange, resetShortcode, { shortcode, setShortcode, shortcodeRef }] = useTextInput("shortcode", { - validator: function validateShortcode(code) { - // 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"; - } + const category = useComboBoxInput("category"); - if (code.toLowerCase() != code) { - return "Shortcode must be lowercase"; - } - - if (!shortcodeRegex.test(code)) { - return "Shortcode must only contain lowercase letters, numbers, and underscores"; - } - - return ""; - } - }); - - const [categoryState, resetCategory, { category }] = useComboBoxInput("category"); + const [submitForm, result] = useFormSubmit({ + shortcode, image, category + }, query.useAddEmojiMutation()); React.useEffect(() => { - if (shortcode.length == 0) { - if (image != undefined) { - let [name, _ext] = image.name.split("."); - setShortcode(name); + if (shortcode.value.length == 0) { + if (image.value != undefined) { + let [name, _ext] = image.value.name.split("."); + shortcode.setter(name); } } - // we explicitly don't want to add 'shortcode' as a dependency here - // because we only want this to update 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]); - - function uploadEmoji(e) { - if (e) { - e.preventDefault(); - } - Promise.try(() => { - return addEmoji({ - image, - shortcode, - category - }).unwrap(); - }).then(() => { - resetFile(); - resetShortcode(); - resetCategory(); - }).catch((e) => { - console.error("Emoji upload error:", e); - }); - } + /* 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]); - let emojiOrShortcode = `:${shortcode}:`; + let emojiOrShortcode = `:${shortcode.value}:`; - if (imageURL != undefined) { + if (image.previewValue != undefined) { emojiOrShortcode = <img className="emoji" - src={imageURL} + src={image.previewValue} title={`:${shortcode}:`} alt={shortcode} />; @@ -126,42 +86,22 @@ module.exports = function NewEmojiForm({ emoji }) { Look at this new custom emoji {emojiOrShortcode} isn't it cool? </FakeToot> - <form onSubmit={uploadEmoji} className="form-flex"> - <div className="form-field file"> - <label className="file-input button" htmlFor="image"> - Browse - </label> - {imageInfo} - <input - className="hidden" - type="file" - id="image" - name="Image" - accept="image/png,image/gif" - onChange={onFileChange} - /> - </div> - - <div className="form-field text"> - <label htmlFor="shortcode"> - Shortcode, must be unique among the instance's local emoji - </label> - <input - type="text" - id="shortcode" - name="Shortcode" - ref={shortcodeRef} - onChange={onShortcodeChange} - value={shortcode} - /> - </div> + <form onSubmit={submitForm} className="form-flex"> + <FileInput + field={image} + accept="image/png,image/gif" + /> + + <TextInput + field={shortcode} + label="Shortcode, must be unique among the instance's local emoji" + /> <CategorySelect - value={category} - categoryState={categoryState} + field={category} /> - <MutateButton text="Upload emoji" result={result} /> + <MutationButton label="Upload emoji" result={result} /> </form> </div> ); diff --git a/web/source/settings/admin/emoji/local/overview.js b/web/source/settings/admin/emoji/local/overview.js index ebfb89695..524cc928f 100644 --- a/web/source/settings/admin/emoji/local/overview.js +++ b/web/source/settings/admin/emoji/local/overview.js @@ -1,25 +1,25 @@ /* - GoToSocial - Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org + GoToSocial + Copyright (C) 2021-2023 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 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. + 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/>. + 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 {Link} = require("wouter"); +const { Link } = require("wouter"); const NewEmojiForm = require("./new-emoji"); @@ -27,33 +27,31 @@ const query = require("../../../lib/query"); const { useEmojiByCategory } = require("../category-select"); const Loading = require("../../../components/loading"); -const base = "/settings/custom-emoji/local"; - -module.exports = function EmojiOverview() { +module.exports = function EmojiOverview({ baseUrl }) { const { data: emoji = [], isLoading, error - } = query.useGetAllEmojiQuery({filter: "domain:local"}); + } = query.useGetAllEmojiQuery({ filter: "domain:local" }); return ( <> <h1>Custom Emoji (local)</h1> - {error && + {error && <div className="error accent">{error}</div> } {isLoading - ? <Loading/> + ? <Loading /> : <> - <EmojiList emoji={emoji}/> - <NewEmojiForm emoji={emoji}/> + <EmojiList emoji={emoji} baseUrl={baseUrl} /> + <NewEmojiForm emoji={emoji} /> </> } </> ); }; -function EmojiList({emoji}) { +function EmojiList({ emoji, baseUrl }) { const emojiByCategory = useEmojiByCategory(emoji); return ( @@ -62,24 +60,23 @@ function EmojiList({emoji}) { <div className="list emoji-list"> {emoji.length == 0 && "No local emoji yet, add one below"} {Object.entries(emojiByCategory).map(([category, entries]) => { - return <EmojiCategory key={category} category={category} entries={entries}/>; + return <EmojiCategory key={category} category={category} entries={entries} baseUrl={baseUrl} />; })} </div> </div> ); } -function EmojiCategory({category, entries}) { +function EmojiCategory({ category, entries, baseUrl }) { return ( <div className="entry"> <b>{category}</b> <div className="emoji-group"> {entries.map((e) => { return ( - <Link key={e.id} to={`${base}/${e.id}`}> - {/* <Link key={e.static_url} to={`${base}`}> */} + <Link key={e.id} to={`${baseUrl}/${e.id}`}> <a> - <img src={e.url} alt={e.shortcode} title={`:${e.shortcode}:`}/> + <img src={e.url} alt={e.shortcode} title={`:${e.shortcode}:`} /> </a> </Link> ); diff --git a/web/source/settings/admin/emoji/local/use-shortcode.js b/web/source/settings/admin/emoji/local/use-shortcode.js new file mode 100644 index 000000000..109914b8b --- /dev/null +++ b/web/source/settings/admin/emoji/local/use-shortcode.js @@ -0,0 +1,61 @@ +/* + GoToSocial + Copyright (C) 2021-2023 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 query = require("../../../lib/query"); +const { useTextInput } = require("../../../lib/form"); + +const shortcodeRegex = /^[a-z0-9_]+$/; + +module.exports = function useShortcode() { + const { + data: emoji = [] + } = query.useGetAllEmojiQuery({ filter: "domain:local" }); + + const emojiCodes = React.useMemo(() => { + return new Set(emoji.map((e) => e.shortcode)); + }, [emoji]); + + return useTextInput("shortcode", { + validator: function validateShortcode(code) { + // technically invalid, but hacky fix to prevent validation error on page load + if (code == "") { 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 ""; + } + }); +};
\ No newline at end of file diff --git a/web/source/settings/admin/emoji/remote/index.js b/web/source/settings/admin/emoji/remote/index.js index fb1e05083..f3bb325e9 100644 --- a/web/source/settings/admin/emoji/remote/index.js +++ b/web/source/settings/admin/emoji/remote/index.js @@ -31,7 +31,7 @@ module.exports = function RemoteEmoji() { data: emoji = [], isLoading, error - } = query.useGetAllEmojiQuery({filter: "domain:local"}); + } = query.useGetAllEmojiQuery({ filter: "domain:local" }); const emojiCodes = React.useMemo(() => { return new Set(emoji.map((e) => e.shortcode)); @@ -40,11 +40,11 @@ module.exports = function RemoteEmoji() { return ( <> <h1>Custom Emoji (remote)</h1> - {error && + {error && <div className="error accent">{error}</div> } {isLoading - ? <Loading/> + ? <Loading /> : <> <ParseFromToot emoji={emoji} emojiCodes={emojiCodes} /> </> diff --git a/web/source/settings/admin/emoji/remote/parse-from-toot.js b/web/source/settings/admin/emoji/remote/parse-from-toot.js index 963ae1a80..309619ea4 100644 --- a/web/source/settings/admin/emoji/remote/parse-from-toot.js +++ b/web/source/settings/admin/emoji/remote/parse-from-toot.js @@ -18,57 +18,35 @@ "use strict"; -const Promise = require("bluebird"); const React = require("react"); -const Redux = require("react-redux"); -const syncpipe = require("syncpipe"); + +const query = require("../../../lib/query"); const { useTextInput, - useComboBoxInput -} = require("../../../components/form"); + useComboBoxInput, + useCheckListInput +} = require("../../../lib/form"); +const useFormSubmit = require("../../../lib/form/submit"); + +const CheckList = require("../../../components/check-list"); const { CategorySelect } = require('../category-select'); -const query = require("../../../lib/query"); -const Loading = require("../../../components/loading"); +const { TextInput } = require("../../../components/form/inputs"); +const MutationButton = require("../../../components/form/mutation-button"); +const { Error } = require("../../../components/error"); 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 [searchStatus, result] = query.useSearchStatusForEmojiMutation(); 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); + if (url.trim().length != 0) { + searchStatus(url); + } } return ( @@ -87,233 +65,137 @@ module.exports = function ParseFromToot({ emojiCodes }) { onChange={onURLChange} value={url} /> - <button disabled={isLoading}> + <button disabled={result.isLoading}> <i className={[ - "fa", - (isLoading + "fa fa-fw", + (result.isLoading ? "fa-refresh fa-spin" : "fa-search") - ].join(" ")} aria-hidden="true" title="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} + <SearchResult result={result} localEmojiCodes={emojiCodes} /> </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); +function SearchResult({ result, localEmojiCodes }) { + const { error, data, isSuccess, isError } = result; - 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 (!(isSuccess || isError)) { + return null; + } - if (some && !all) { - setToggleAllState(2); - toggleAllRef.current.indeterminate = true; - } else { - setToggleAllState(all ? 1 : 0); - toggleAllRef.current.indeterminate = false; - } - }, [emojiState, toggleAllRef]); + if (error == "NONE_FOUND") { + return "No results found"; + } else if (error == "LOCAL_INSTANCE") { + return <b>This is a local user/toot, all referenced emoji are already on your instance</b>; + } else if (error != undefined) { + return <Error error={result.error} />; + } - function updateEmoji(shortcode, value) { - setEmojiState({ - ...emojiState, - [shortcode]: { - ...emojiState[shortcode], - ...value - } - }); + if (data.list.length == 0) { + return <b>This {data.type == "statuses" ? "toot" : "account"} doesn't use any custom emoji</b>; } - function toggleAll(e) { - let selectAll = e.target.checked; + return ( + <CopyEmojiForm + localEmojiCodes={localEmojiCodes} + type={data.type} + domain={data.domain} + emojiList={data.list} + /> + ); +} - if (toggleAllState == 2) { // indeterminate - selectAll = false; - } +function CopyEmojiForm({ localEmojiCodes, type, emojiList }) { + const form = { + selectedEmoji: useCheckListInput("selectedEmoji", { + entries: emojiList, + uniqueKey: "shortcode" + }), + category: useComboBoxInput("category") + }; - setEmojiState(updateEmojiState(emojiState, selectAll)); - setToggleAllState(selectAll); - } + const [formSubmit, result] = useFormSubmit(form, query.usePatchRemoteEmojisMutation(), { changedOnly: false }); - 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); - } - }); - } + const buttonsInactive = form.selectedEmoji.someSelected + ? {} + : { + disabled: true, + title: "No emoji selected, cannot perform any actions" + }; 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} - /> + <form onSubmit={formSubmit}> + <CheckList + field={form.selectedEmoji} + Component={EmojiEntry} + localEmojiCodes={localEmojiCodes} + /> + + <CategorySelect + field={form.category} + /> + + <div className="action-buttons row"> + <MutationButton name="copy" label="Copy to local emoji" result={result} showError={false} {...buttonsInactive} /> + <MutationButton name="disable" label="Disable" result={result} className="button danger" showError={false} {...buttonsInactive} /> + </div> + {result.error && ( + Array.isArray(result.error) + ? <ErrorList errors={result.error} /> + : <Error error={result.error} /> + )} + </form> + </div> + ); +} - <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>} +function ErrorList({ errors }) { + return ( + <div className="error"> + One or multiple emoji failed to process: + {errors.map(([shortcode, err]) => ( + <div key={shortcode}> + <b>{shortcode}:</b> {err} + </div> + ))} </div> ); } -function EmojiEntry({ emoji, localEmojiCodes, updateEmoji, checked }) { - const [onShortcodeChange, _resetShortcode, { shortcode, shortcodeRef, shortcodeValid }] = useTextInput("shortcode", { +function EmojiEntry({ entry: emoji, localEmojiCodes, onChange }) { + const shortcodeField = useTextInput("shortcode", { defaultValue: emoji.shortcode, validator: function validateShortcode(code) { - return (checked && localEmojiCodes.has(code)) + return (emoji.checked && localEmojiCodes.has(code)) ? "Shortcode already in use" : ""; } }); React.useEffect(() => { - updateEmoji({ valid: shortcodeValid }); + onChange({ valid: shortcodeField.valid }); /* eslint-disable-next-line react-hooks/exhaustive-deps */ - }, [shortcodeValid]); + }, [shortcodeField.valid]); 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} + <TextInput + field={shortcodeField} onChange={(e) => { - onShortcodeChange(e); - updateEmoji({ shortcode: e.target.value, checked: true }); + shortcodeField.onChange(e); + onChange({ shortcode: e.target.value, checked: true }); }} - value={shortcode} /> - </label> + </> ); }
\ No newline at end of file |