gtsocial-umbx

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README | LICENSE

commit 4b8d7bd952dd97091d1baddeab10213e9c38cef3
parent ce615b5d59456045212572c93c4fbfaa12639cf7
Author: f0x52 <f0x@cthu.lu>
Date:   Sun, 11 Dec 2022 16:00:23 +0100

[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:
Mweb/source/css/base.css | 11+++++++++++
Dweb/source/settings/admin/emoji/detail.js | 169-------------------------------------------------------------------------------
Dweb/source/settings/admin/emoji/index.js | 40----------------------------------------
Aweb/source/settings/admin/emoji/local/detail.js | 174+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/source/settings/admin/emoji/local/index.js | 40++++++++++++++++++++++++++++++++++++++++
Aweb/source/settings/admin/emoji/local/new-emoji.js | 169+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/source/settings/admin/emoji/local/overview.js | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dweb/source/settings/admin/emoji/new-emoji.js | 148-------------------------------------------------------------------------------
Dweb/source/settings/admin/emoji/overview.js | 90-------------------------------------------------------------------------------
Aweb/source/settings/admin/emoji/remote/index.js | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/source/settings/admin/emoji/remote/parse-from-toot.js | 320+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mweb/source/settings/admin/federation.js | 7+++++--
Mweb/source/settings/components/form/text.jsx | 16++++++++--------
Aweb/source/settings/components/loading.jsx | 28++++++++++++++++++++++++++++
Mweb/source/settings/index.js | 9+++++++--
Mweb/source/settings/lib/query/custom-emoji.js | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mweb/source/settings/style.css | 48++++++++++++++++++++++++++++++++++++++++++++++++
17 files changed, 1053 insertions(+), 459 deletions(-)

diff --git 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/detail.js @@ -1,168 +0,0 @@ -/* - 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 { useRoute, Link, Redirect } = require("wouter"); - -const { CategorySelect } = require("./category-select"); -const { useComboBoxInput, useFileInput } = require("../../components/form"); - -const query = require("../../lib/query"); -const FakeToot = require("../../components/fake-toot"); - -const base = "/settings/admin/custom-emoji"; - -module.exports = function EmojiDetailRoute() { - let [_match, params] = useRoute(`${base}/:emojiId`); - if (params?.emojiId == undefined) { - return <Redirect to={base}/>; - } else { - return ( - <div className="emoji-detail"> - <Link to={base}><a>&lt; go back</a></Link> - <EmojiDetailData emojiId={params.emojiId}/> - </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 "Loading..."; - } else { - return <EmojiDetail emoji={emoji}/>; - } -} - -function EmojiDetail({emoji}) { - const [modifyEmoji, modifyResult] = query.useEditEmojiMutation(); - - const [isNewCategory, setIsNewCategory] = React.useState(false); - - const [categoryState, _resetCategory, { category }] = useComboBoxInput("category", {defaultValue: emoji.category}); - - const [onFileChange, _resetFile, { image, imageURL, imageInfo }] = useFileInput("image", { - withPreview: true, - maxSize: 50 * 1024 - }); - - function modifyCategory() { - modifyEmoji({id: emoji.id, category: category.trim()}); - } - - function modifyImage() { - modifyEmoji({id: emoji.id, image: image}); - } - - 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}/> - <div> - <h2>{emoji.shortcode}</h2> - <DeleteButton id={emoji.id}/> - </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>} - - <div className="update-category"> - <CategorySelect - value={category} - categoryState={categoryState} - setIsNew={setIsNewCategory} - > - <button style={{visibility: (isNewCategory ? "initial" : "hidden")}} onClick={modifyCategory}> - Create - </button> - </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> - - <FakeToot> - Look at this new custom emoji <img - className="emoji" - src={imageURL ?? emoji.url} - title={`:${emoji.shortcode}:`} - alt={emoji.shortcode} - /> isn&apos;t it cool? - </FakeToot> - </div> - </div> - </> - ); -} - -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/index.js b/web/source/settings/admin/emoji/index.js @@ -1,40 +0,0 @@ -/* - 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 {Switch, Route} = require("wouter"); - -const EmojiOverview = require("./overview"); -const EmojiDetail = require("./detail"); - -const base = "/settings/admin/custom-emoji"; - -module.exports = function CustomEmoji() { - return ( - <> - <Switch> - <Route path={`${base}/:emojiId`}> - <EmojiDetail /> - </Route> - <EmojiOverview /> - </Switch> - </> - ); -}; diff --git a/web/source/settings/admin/emoji/local/detail.js b/web/source/settings/admin/emoji/local/detail.js @@ -0,0 +1,173 @@ +/* + 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 { useRoute, Link, Redirect } = require("wouter"); + +const { CategorySelect } = require("../category-select"); +const { useComboBoxInput, useFileInput } = require("../../../components/form"); + +const query = require("../../../lib/query"); +const FakeToot = require("../../../components/fake-toot"); +const Loading = require("../../../components/loading"); + +const base = "/settings/custom-emoji/local"; + +module.exports = function EmojiDetailRoute() { + let [_match, params] = useRoute(`${base}/:emojiId`); + if (params?.emojiId == undefined) { + return <Redirect to={base}/>; + } else { + return ( + <div className="emoji-detail"> + <Link to={base}><a>&lt; go back</a></Link> + <EmojiDetailData emojiId={params.emojiId}/> + </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(); + + const [isNewCategory, setIsNewCategory] = React.useState(false); + + const [categoryState, _resetCategory, { category }] = useComboBoxInput("category", {defaultValue: emoji.category}); + + const [onFileChange, _resetFile, { image, imageURL, imageInfo }] = useFileInput("image", { + withPreview: true, + maxSize: 50 * 1024 + }); + + function modifyCategory() { + modifyEmoji({id: emoji.id, category: category.trim()}); + } + + function modifyImage() { + modifyEmoji({id: emoji.id, image: image}); + } + + 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}/> + <div> + <h2>{emoji.shortcode}</h2> + <DeleteButton id={emoji.id}/> + </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>} + + <div className="update-category"> + <CategorySelect + value={category} + categoryState={categoryState} + setIsNew={setIsNewCategory} + > + <button style={{visibility: (isNewCategory ? "initial" : "hidden")}} onClick={modifyCategory}> + Create + </button> + </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> + + <FakeToot> + Look at this new custom emoji <img + className="emoji" + src={imageURL ?? emoji.url} + title={`:${emoji.shortcode}:`} + alt={emoji.shortcode} + /> isn&apos;t it cool? + </FakeToot> + </div> + </div> + </> + ); +} + +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 @@ -0,0 +1,40 @@ +/* + 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 {Switch, Route} = require("wouter"); + +const EmojiOverview = require("./overview"); +const EmojiDetail = require("./detail"); + +const base = "/settings/custom-emoji/local"; + +module.exports = function CustomEmoji() { + return ( + <> + <Switch> + <Route path={`${base}/:emojiId`}> + <EmojiDetail /> + </Route> + <EmojiOverview /> + </Switch> + </> + ); +}; diff --git a/web/source/settings/admin/emoji/local/new-emoji.js b/web/source/settings/admin/emoji/local/new-emoji.js @@ -0,0 +1,168 @@ +/* + 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 FakeToot = require("../../../components/fake-toot"); +const MutateButton = require("../../../components/mutation-button"); + +const { + useTextInput, + useFileInput, + useComboBoxInput +} = require("../../../components/form"); + +const query = require("../../../lib/query"); +const { CategorySelect } = require('../category-select'); + +const shortcodeRegex = /^[a-z0-9_]+$/; + +module.exports = function NewEmojiForm({ emoji }) { + const emojiCodes = React.useMemo(() => { + return new Set(emoji.map((e) => e.shortcode)); + }, [emoji]); + + const [addEmoji, result] = query.useAddEmojiMutation(); + + const [onFileChange, resetFile, { image, imageURL, imageInfo }] = useFileInput("image", { + withPreview: true, + maxSize: 50 * 1024 + }); + + 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"; + } + + 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"); + + React.useEffect(() => { + if (shortcode.length == 0) { + if (image != undefined) { + let [name, _ext] = image.name.split("."); + setShortcode(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); + }); + } + + let emojiOrShortcode = `:${shortcode}:`; + + if (imageURL != undefined) { + emojiOrShortcode = <img + className="emoji" + src={imageURL} + title={`:${shortcode}:`} + alt={shortcode} + />; + } + + return ( + <div> + <h2>Add new custom emoji</h2> + + <FakeToot> + Look at this new custom emoji {emojiOrShortcode} isn&apos;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> + + <CategorySelect + value={category} + categoryState={categoryState} + /> + + <MutateButton text="Upload emoji" result={result} /> + </form> + </div> + ); +}; +\ No newline at end of file diff --git a/web/source/settings/admin/emoji/local/overview.js b/web/source/settings/admin/emoji/local/overview.js @@ -0,0 +1,90 @@ +/* + 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 {Link} = require("wouter"); + +const NewEmojiForm = require("./new-emoji"); + +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() { + const { + data: emoji = [], + isLoading, + error + } = query.useGetAllEmojiQuery({filter: "domain:local"}); + + return ( + <> + <h1>Custom Emoji (local)</h1> + {error && + <div className="error accent">{error}</div> + } + {isLoading + ? <Loading/> + : <> + <EmojiList emoji={emoji}/> + <NewEmojiForm emoji={emoji}/> + </> + } + </> + ); +}; + +function EmojiList({emoji}) { + const emojiByCategory = useEmojiByCategory(emoji); + + return ( + <div> + <h2>Overview</h2> + <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}/>; + })} + </div> + </div> + ); +} + +function EmojiCategory({category, entries}) { + 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}`}> */} + <a> + <img src={e.url} alt={e.shortcode} title={`:${e.shortcode}:`}/> + </a> + </Link> + ); + })} + </div> + </div> + ); +} +\ No newline at end of file diff --git a/web/source/settings/admin/emoji/new-emoji.js b/web/source/settings/admin/emoji/new-emoji.js @@ -1,147 +0,0 @@ -/* - 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 FakeToot = require("../../components/fake-toot"); -const MutateButton = require("../../components/mutation-button"); - -const { - useTextInput, - useFileInput, - useComboBoxInput -} = require("../../components/form"); - -const query = require("../../lib/query"); -const { CategorySelect } = require('./category-select'); - -module.exports = function NewEmojiForm({ emoji }) { - const emojiCodes = React.useMemo(() => { - return new Set(emoji.map((e) => e.shortcode)); - }, [emoji]); - - const [addEmoji, result] = query.useAddEmojiMutation(); - - const [onFileChange, resetFile, { image, imageURL, imageInfo }] = useFileInput("image", { - withPreview: true, - maxSize: 50 * 1024 - }); - - const [onShortcodeChange, resetShortcode, { shortcode, setShortcode, shortcodeRef }] = useTextInput("shortcode", { - validator: function validateShortcode(code) { - return emojiCodes.has(code) - ? "Shortcode already in use" - : ""; - } - }); - - const [categoryState, resetCategory, { category }] = useComboBoxInput("category"); - - React.useEffect(() => { - if (shortcode.length == 0) { - if (image != undefined) { - let [name, _ext] = image.name.split("."); - setShortcode(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 - }); - }).then(() => { - resetFile(); - resetShortcode(); - resetCategory(); - }); - } - - let emojiOrShortcode = `:${shortcode}:`; - - if (imageURL != undefined) { - emojiOrShortcode = <img - className="emoji" - src={imageURL} - title={`:${shortcode}:`} - alt={shortcode} - />; - } - - return ( - <div> - <h2>Add new custom emoji</h2> - - <FakeToot> - Look at this new custom emoji {emojiOrShortcode} isn&apos;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> - - <CategorySelect - value={category} - categoryState={categoryState} - /> - - <MutateButton text="Upload emoji" result={result} /> - </form> - </div> - ); -}; -\ No newline at end of file diff --git a/web/source/settings/admin/emoji/overview.js b/web/source/settings/admin/emoji/overview.js @@ -1,89 +0,0 @@ -/* - 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 {Link} = require("wouter"); - -const NewEmojiForm = require("./new-emoji"); - -const query = require("../../lib/query"); -const { useEmojiByCategory } = require("./category-select"); - -const base = "/settings/admin/custom-emoji"; - -module.exports = function EmojiOverview() { - const { - data: emoji = [], - isLoading, - error - } = query.useGetAllEmojiQuery({filter: "domain:local"}); - - return ( - <> - <h1>Custom Emoji</h1> - {error && - <div className="error accent">{error}</div> - } - {isLoading - ? "Loading..." - : <> - <EmojiList emoji={emoji}/> - <NewEmojiForm emoji={emoji}/> - </> - } - </> - ); -}; - -function EmojiList({emoji}) { - const emojiByCategory = useEmojiByCategory(emoji); - - return ( - <div> - <h2>Overview</h2> - <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}/>; - })} - </div> - </div> - ); -} - -function EmojiCategory({category, entries}) { - 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}`}> */} - <a> - <img src={e.url} alt={e.shortcode} title={`:${e.shortcode}:`}/> - </a> - </Link> - ); - })} - </div> - </div> - ); -} -\ 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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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