From 9b6a54032c97ae1c1dd38586c43395d83510894b Mon Sep 17 00:00:00 2001 From: f0x Date: Thu, 12 Jan 2023 23:33:39 +0000 Subject: [PATCH] refactor custom-emoji, progress on federation bulk --- web/source/settings/admin/_federation.js | 282 ++++++++++++++++++ .../settings/admin/emoji/category-select.jsx | 18 +- .../settings/admin/emoji/local/detail.js | 148 +++++---- .../settings/admin/emoji/local/index.js | 6 +- .../settings/admin/emoji/local/new-emoji.js | 135 +++------ .../settings/admin/emoji/local/overview.js | 27 +- .../admin/emoji/local/use-shortcode.js | 61 ++++ .../admin/emoji/remote/parse-from-toot.js | 210 +++++++------ .../admin/federation/import-export.js | 71 ++++- web/source/settings/admin/federation/index.js | 5 +- .../settings/admin/federation/overview.js | 5 +- web/source/settings/components/combo-box.jsx | 8 +- web/source/settings/components/error.jsx | 35 ++- .../settings/components/form/inputs.jsx | 2 +- .../components/form/mutation-button.jsx | 27 +- web/source/settings/components/login.jsx | 28 +- web/source/settings/index.js | 8 +- web/source/settings/lib/api/admin.js | 6 +- .../lib/form/{combobox.jsx => combo-box.jsx} | 12 +- .../settings/lib/form/form-with-data.jsx | 12 +- web/source/settings/lib/form/index.js | 11 +- web/source/settings/lib/form/submit.js | 40 ++- web/source/settings/lib/get-views.js | 2 +- web/source/settings/lib/import-export.js | 65 ++++ .../lib/query/admin/federation-bulk.js | 24 ++ .../lib/query/{admin.js => admin/index.js} | 7 +- web/source/settings/lib/query/custom-emoji.js | 113 ++++--- web/source/settings/style.css | 31 +- 28 files changed, 969 insertions(+), 430 deletions(-) create mode 100644 web/source/settings/admin/_federation.js create mode 100644 web/source/settings/admin/emoji/local/use-shortcode.js rename web/source/settings/lib/form/{combobox.jsx => combo-box.jsx} (83%) create mode 100644 web/source/settings/lib/import-export.js create mode 100644 web/source/settings/lib/query/admin/federation-bulk.js rename web/source/settings/lib/query/{admin.js => admin/index.js} (95%) diff --git a/web/source/settings/admin/_federation.js b/web/source/settings/admin/_federation.js new file mode 100644 index 000000000..9ec1f2a63 --- /dev/null +++ b/web/source/settings/admin/_federation.js @@ -0,0 +1,282 @@ +/* + 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 . +*/ + +"use strict"; + +const Promise = require("bluebird"); +const React = require("react"); +const Redux = require("react-redux"); +const { Switch, Route, Link, Redirect, useRoute, useLocation } = require("wouter"); +const fileDownload = require("js-file-download"); + +const { formFields } = require("../components/form-fields"); + +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 { matchSorter } = require("match-sorter"); + +const base = "/settings/admin/federation"; + +module.exports = function AdminSettings() { + const dispatch = Redux.useDispatch(); + const loadedBlockedInstances = Redux.useSelector(state => state.admin.loadedBlockedInstances); + + React.useEffect(() => { + if (!loadedBlockedInstances) { + Promise.try(() => { + return dispatch(api.admin.fetchDomainBlocks()); + }); + } + }, [dispatch, loadedBlockedInstances]); + + if (!loadedBlockedInstances) { + return ( +
+

Federation

+
+ +
+
+ ); + } + + return ( + + + + + + + ); +}; + +function InstanceOverview() { + const [filter, setFilter] = React.useState(""); + const blockedInstances = Redux.useSelector(state => state.admin.blockedInstances); + const [_location, setLocation] = useLocation(); + + const filteredInstances = React.useMemo(() => { + return matchSorter(Object.values(blockedInstances), filter, { keys: ["domain"] }); + }, [blockedInstances, filter]); + + function filterFormSubmit(e) { + e.preventDefault(); + setLocation(`${base}/${filter}`); + } + + return ( + <> +

Federation

+ Here you can see an overview of blocked instances. + +
+

Blocked instances

+
+ setFilter(e.target.value)} /> + Add block +
+
+ {filteredInstances.map((entry) => { + return ( + + + + {entry.domain} + + + {new Date(entry.created_at).toLocaleString()} + + + + ); + })} +
+
+ + + + ); +} + +const Bulk = formFields(adminActions.updateBulkBlockVal, (state) => state.admin.bulkBlock); +function BulkBlocking() { + const dispatch = Redux.useDispatch(); + const { bulkBlock, blockedInstances } = Redux.useSelector(state => state.admin); + + const [errorMsg, setError] = React.useState(""); + const [statusMsg, setStatus] = React.useState(""); + + function importBlocks() { + setStatus("Processing"); + setError(""); + return Promise.try(() => { + return dispatch(api.admin.bulkDomainBlock()); + }).then(({ success, invalidDomains }) => { + return Promise.try(() => { + return resetBulk(); + }).then(() => { + dispatch(adminActions.updateBulkBlockVal(["list", invalidDomains.join("\n")])); + + let stat = ""; + if (success == 0) { + return setError("No valid domains in import"); + } else if (success == 1) { + stat = "Imported 1 domain"; + } else { + stat = `Imported ${success} domains`; + } + + if (invalidDomains.length > 0) { + if (invalidDomains.length == 1) { + stat += ", input contained 1 invalid domain."; + } else { + stat += `, input contained ${invalidDomains.length} invalid domains.`; + } + } else { + stat += "!"; + } + + setStatus(stat); + }); + }).catch((e) => { + setError(e.message); + setStatus(""); + }); + } + + function exportBlocks() { + return Promise.try(() => { + setStatus("Exporting"); + setError(""); + let asJSON = bulkBlock.exportType.startsWith("json"); + let _asCSV = bulkBlock.exportType.startsWith("csv"); + + let exportList = Object.values(blockedInstances).map((entry) => { + if (asJSON) { + return { + domain: entry.domain, + public_comment: entry.public_comment + }; + } else { + return entry.domain; + } + }); + + if (bulkBlock.exportType == "json") { + return dispatch(adminActions.updateBulkBlockVal(["list", JSON.stringify(exportList)])); + } else if (bulkBlock.exportType == "json-download") { + return fileDownload(JSON.stringify(exportList), "block-export.json"); + } else if (bulkBlock.exportType == "plain") { + return dispatch(adminActions.updateBulkBlockVal(["list", exportList.join("\n")])); + } + }).then(() => { + setStatus("Exported!"); + }).catch((e) => { + setError(e.message); + setStatus(""); + }); + } + + function resetBulk(e) { + if (e != undefined) { + e.preventDefault(); + } + return dispatch(adminActions.resetBulkBlockVal()); + } + + function disableInfoFields(props = {}) { + if (bulkBlock.list[0] == "[") { + return { + ...props, + disabled: true, + placeHolder: "Domain list is a JSON import, input disabled" + }; + } else { + return props; + } + } + + return ( +
+

Import / Export reset

+ + + + + + + + +
+ +
+ +
+
+ +
+ +
+ + + + + + + + + + } /> +
+
+
+ {errorMsg.length > 0 && +
{errorMsg}
+ } + {statusMsg.length > 0 && +
{statusMsg}
+ } +
+
+
+ ); +} \ No newline at end of file diff --git a/web/source/settings/admin/emoji/category-select.jsx b/web/source/settings/admin/emoji/category-select.jsx index d22534ea8..605915e2f 100644 --- a/web/source/settings/admin/emoji/category-select.jsx +++ b/web/source/settings/admin/emoji/category-select.jsx @@ -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 ( <> - {categoryState.value = e.target.value;}}/>; + { field.value = e.target.value; }} />; ); } else if (isLoading) { - return ; + return ; } return ( ); diff --git a/web/source/settings/admin/emoji/local/detail.js b/web/source/settings/admin/emoji/local/detail.js index c509ab4f4..4fb59e9e0 100644 --- a/web/source/settings/admin/emoji/local/detail.js +++ b/web/source/settings/admin/emoji/local/detail.js @@ -19,15 +19,21 @@ "use strict"; const React = require("react"); - const { useRoute, Link, Redirect } = require("wouter"); -const { CategorySelect } = require("../category-select"); -const { useComboBoxInput, useFileInput } = require("../../../lib/form"); - const query = require("../../../lib/query"); + +const { useComboBoxInput, useFileInput, useValue } = require("../../../lib/form"); +const { CategorySelect } = require("../category-select"); + +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"; @@ -39,57 +45,41 @@ module.exports = function EmojiDetailRoute() { return (
< go back - +
); } }; -function EmojiDetailData({ emojiId }) { - const { currentData: emoji, isLoading, error } = query.useGetEmojiQuery(emojiId); +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 + }) + }; - if (error) { - return ( -
- {error.status}: {error.data.error} -
- ); - } else if (isLoading) { - return ( -
- -
- ); - } else { - return ; - } -} - -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 }); - } + const [modifyEmoji, result] = useFormSubmit(form, query.useEditEmojiMutation()); + // Automatic submitting of category change React.useEffect(() => { - if (category != emoji.category && !categoryState.open && !isNewCategory && category?.trim().length > 0) { - modifyEmoji({ id: emoji.id, category: category.trim() }); + if ( + form.category.hasChanged() && + !form.category.state.open && + !form.category.isNew) { + modifyEmoji(); } - }, [isNewCategory, category, categoryState.open, emoji.category, emoji.id, modifyEmoji]); + }, [form.category.hasChanged(), form.category.isNew, form.category.state.open]); + + const [deleteEmoji, deleteResult] = query.useDeleteEmojiMutation(); + + if (deleteResult.isSuccess) { + return ; + } + + console.log(form.category); return ( <> @@ -97,58 +87,62 @@ function EmojiDetail({ emoji }) { {emoji.shortcode}

{emoji.shortcode}

- + deleteEmoji(emoji.id)} + className="danger button-inline" + showError={false} + result={deleteResult} + />
-
-

Modify this emoji {modifyResult.isLoading && "(processing..)"}

- - {modifyResult.error &&
- {modifyResult.error.status}: {modifyResult.error.data.error} -
} +
+

Modify this emoji {result.isLoading && }

- +
- Image -
- - {imageInfo} - -
+ - + Look at this new custom emoji {emoji.shortcode} isn't it cool? + + {result.error && } + {deleteResult.error && }
-
+ ); } diff --git a/web/source/settings/admin/emoji/local/index.js b/web/source/settings/admin/emoji/local/index.js index 4160fe41d..0cf4254f7 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"); @@ -31,9 +31,9 @@ module.exports = function CustomEmoji() { <> - + - + ); diff --git a/web/source/settings/admin/emoji/local/new-emoji.js b/web/source/settings/admin/emoji/local/new-emoji.js index 3a539686b..1424eea96 100644 --- a/web/source/settings/admin/emoji/local/new-emoji.js +++ b/web/source/settings/admin/emoji/local/new-emoji.js @@ -21,98 +21,65 @@ const Promise = require('bluebird'); const React = require("react"); -const FakeToot = require("../../../components/fake-toot"); -const MutationButton = require("../../../components/form/mutation-button"); +const query = require("../../../lib/query"); const { useTextInput, useFileInput, useComboBoxInput } = require("../../../lib/form"); +const useShortcode = require("./use-shortcode"); + +const useFormSubmit = require("../../../lib/form/submit"); + +const { + TextInput, FileInput +} = require("../../../components/form/inputs"); -const query = require("../../../lib/query"); const { CategorySelect } = require('../category-select'); +const FakeToot = require("../../../components/fake-toot"); +const MutationButton = require("../../../components/form/mutation-button"); -const shortcodeRegex = /^[a-z0-9_]+$/; +module.exports = function NewEmojiForm() { + const shortcode = useShortcode(); -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", { + 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 ""; } + const category = useComboBoxInput("category"); - if (emojiCodes.has(code)) { - return "Shortcode already in use"; - } + const [submitForm, result] = useFormSubmit({ + shortcode, image, category + }, query.useAddEmojiMutation()); - if (code.length < 2 || code.length > 30) { - return "Shortcode must be between 2 and 30 characters"; - } + // const [onFileChange, resetFile, { image, imageURL, imageInfo }] = useFileInput("image", { + // withPreview: true, + // maxSize: 50 * 1024 + // }); - 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 [categoryState, resetCategory, { category }] = useComboBoxInput("category"); 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]); + }, [image.value]); - function uploadEmoji(e) { - if (e) { - e.preventDefault(); - } + let emojiOrShortcode = `:${shortcode.value}:`; - Promise.try(() => { - return addEmoji({ - image, - shortcode, - category - }); - }).then((res) => { - if (res.error == undefined) { - resetFile(); - resetShortcode(); - resetCategory(); - } - }); - } - - let emojiOrShortcode = `:${shortcode}:`; - - if (imageURL != undefined) { + if (image.previewValue != undefined) { emojiOrShortcode = {shortcode}; @@ -126,39 +93,19 @@ module.exports = function NewEmojiForm({ emoji }) { Look at this new custom emoji {emojiOrShortcode} isn't it cool? -
-
- - {imageInfo} - -
+ + -
- - -
+ diff --git a/web/source/settings/admin/emoji/local/overview.js b/web/source/settings/admin/emoji/local/overview.js index ebfb89695..99c3122e1 100644 --- a/web/source/settings/admin/emoji/local/overview.js +++ b/web/source/settings/admin/emoji/local/overview.js @@ -19,7 +19,7 @@ "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 ( <>

Custom Emoji (local)

- {error && + {error &&
{error}
} {isLoading - ? + ? : <> - - + + } ); }; -function EmojiList({emoji}) { +function EmojiList({ emoji, baseUrl }) { const emojiByCategory = useEmojiByCategory(emoji); return ( @@ -62,24 +60,23 @@ function EmojiList({emoji}) {
{emoji.length == 0 && "No local emoji yet, add one below"} {Object.entries(emojiByCategory).map(([category, entries]) => { - return ; + return ; })}
); } -function EmojiCategory({category, entries}) { +function EmojiCategory({ category, entries, baseUrl }) { return (
{category}
{entries.map((e) => { return ( - - {/* */} + - {e.shortcode} + {e.shortcode} ); 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..a8ac30dc1 --- /dev/null +++ b/web/source/settings/admin/emoji/local/use-shortcode.js @@ -0,0 +1,61 @@ +/* + 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 . +*/ + +"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/parse-from-toot.js b/web/source/settings/admin/emoji/remote/parse-from-toot.js index dc56ae48a..e860512e4 100644 --- a/web/source/settings/admin/emoji/remote/parse-from-toot.js +++ b/web/source/settings/admin/emoji/remote/parse-from-toot.js @@ -18,10 +18,9 @@ "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, @@ -34,44 +33,15 @@ 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 This is a local user/toot, all referenced emoji are already on your instance; - } - - if (data.list.length == 0) { - return This {data.type == "statuses" ? "toot" : "account"} doesn't use any custom emoji; - } - - return ( - - ); - }, [isSuccess, data, instanceDomain, emojiCodes]); - function submitSearch(e) { e.preventDefault(); if (url.trim().length != 0) { @@ -95,101 +65,143 @@ module.exports = function ParseFromToot({ emojiCodes }) { onChange={onURLChange} value={url} /> -