diff options
author | 2023-01-18 14:45:14 +0100 | |
---|---|---|
committer | 2023-01-18 14:45:14 +0100 | |
commit | 9b139b632098e6741b10fa87ff6224dcb5045947 (patch) | |
tree | c72b5c666ed01db7d1a18e531e5e01e07f504a46 /web/source/settings/admin | |
parent | [chore] Change default sqlite busy timeout to 5m (#1352) (diff) | |
download | gotosocial-9b139b632098e6741b10fa87ff6224dcb5045947.tar.xz |
[frogend] Settings refactor (#1318)
* yakshave new form field structure
* fully refactor user profile settings form
* use rtk query api for profile settings
* refactor user post settings
* refactor password change form
* refactor admin settings
* FormWithData structure for user forms
* admin actions refactor
* whitespace
* fix user settings data prop
* remove superfluous logging
* cleanup old code
* refactor federation/suspend (overview, detail)
* mostly abstracted (emoji) checkbox list
* refactor parse-from-toot
* refactor custom-emoji, progress on federation bulk
* loading icon styling to prevent big spinny
* refactor federation import-export interface
* cleanup old files
* [chore] Update/add license headers for 2023
* redux fixes
* text-field exports
* appease the linter
* refactor authentication with RTK Query
* fix login/logout state transition weirdness
* fixes/cleanup
* small linter-related fixes
* add eslint license header check, fix existing files
* remove old code, clarify comment
* clarify suspend on subdomains
* collapse if/else
* fa-fw width info comment
Diffstat (limited to 'web/source/settings/admin')
-rw-r--r-- | web/source/settings/admin/actions.js | 41 | ||||
-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 | ||||
-rw-r--r-- | web/source/settings/admin/federation.js | 394 | ||||
-rw-r--r-- | web/source/settings/admin/federation/detail.js | 146 | ||||
-rw-r--r-- | web/source/settings/admin/federation/import-export.js | 307 | ||||
-rw-r--r-- | web/source/settings/admin/federation/index.js | 44 | ||||
-rw-r--r-- | web/source/settings/admin/federation/overview.js | 100 | ||||
-rw-r--r-- | web/source/settings/admin/settings.js | 107 |
15 files changed, 1012 insertions, 938 deletions
diff --git a/web/source/settings/admin/actions.js b/web/source/settings/admin/actions.js index 66caa1794..b91d81e14 100644 --- a/web/source/settings/admin/actions.js +++ b/web/source/settings/admin/actions.js @@ -19,42 +19,43 @@ "use strict"; const React = require("react"); -const Redux = require("react-redux"); -const Submit = require("../components/submit"); +const query = require("../lib/query"); -const api = require("../lib/api"); -const submit = require("../lib/submit"); +const { useTextInput } = require("../lib/form"); +const { TextInput } = require("../components/form/inputs"); -module.exports = function AdminActionPanel() { - const dispatch = Redux.useDispatch(); +const MutationButton = require("../components/form/mutation-button"); - const [days, setDays] = React.useState(30); +module.exports = function AdminActionPanel() { + const daysField = useTextInput("days", { defaultValue: 30 }); - const [errorMsg, setError] = React.useState(""); - const [statusMsg, setStatus] = React.useState(""); + const [mediaCleanup, mediaCleanupResult] = query.useMediaCleanupMutation(); - const removeMedia = submit( - () => dispatch(api.admin.mediaCleanup(days)), - {setStatus, setError} - ); + function submitMediaCleanup(e) { + e.preventDefault(); + mediaCleanup(daysField.value); + } return ( <> <h1>Admin Actions</h1> - <div> + <form onSubmit={submitMediaCleanup}> <h2>Media cleanup</h2> <p> Clean up remote media older than the specified number of days. If the remote instance is still online they will be refetched when needed. Also cleans up unused headers and avatars from the media cache. </p> - <div> - <label htmlFor="days">Days: </label> - <input id="days" type="number" value={days} onChange={(e) => setDays(e.target.value)}/> - </div> - <Submit onClick={removeMedia} label="Remove media" errorMsg={errorMsg} statusMsg={statusMsg} /> - </div> + <TextInput + field={daysField} + label="Days" + type="number" + min="0" + placeholder="30" + /> + <MutationButton label="Remove old media" result={mediaCleanupResult} /> + </form> </> ); };
\ 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..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 diff --git a/web/source/settings/admin/federation.js b/web/source/settings/admin/federation.js deleted file mode 100644 index b7658f444..000000000 --- a/web/source/settings/admin/federation.js +++ /dev/null @@ -1,394 +0,0 @@ -/* - 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 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"; - -// const { -// TextInput, -// TextArea, -// File -// } = require("../components/form-fields").formFields(adminActions.setAdminSettingsVal, (state) => state.instances.adminSettings); - -module.exports = function AdminSettings() { - const dispatch = Redux.useDispatch(); - // const instance = Redux.useSelector(state => state.instances.adminSettings); - const loadedBlockedInstances = Redux.useSelector(state => state.admin.loadedBlockedInstances); - - React.useEffect(() => { - if (!loadedBlockedInstances ) { - Promise.try(() => { - return dispatch(api.admin.fetchDomainBlocks()); - }); - } - }, [dispatch, loadedBlockedInstances]); - - if (!loadedBlockedInstances) { - return ( - <div> - <h1>Federation</h1> - <div> - <Loading/> - </div> - </div> - ); - } - - return ( - <Switch> - <Route path={`${base}/:domain`}> - <InstancePageWrapped /> - </Route> - <InstanceOverview /> - </Switch> - ); -}; - -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 ( - <> - <h1>Federation</h1> - Here you can see an overview of blocked instances. - - <div className="instance-list"> - <h2>Blocked instances</h2> - <form action={`${base}/view`} className="filter" role="search" onSubmit={filterFormSubmit}> - <input name="domain" value={filter} onChange={(e) => setFilter(e.target.value)}/> - <Link to={`${base}/${filter}`}><a className="button">Add block</a></Link> - </form> - <div className="list"> - {filteredInstances.map((entry) => { - return ( - <Link key={entry.domain} to={`${base}/${entry.domain}`}> - <a className="entry nounderline"> - <span id="domain"> - {entry.domain} - </span> - <span id="date"> - {new Date(entry.created_at).toLocaleString()} - </span> - </a> - </Link> - ); - })} - </div> - </div> - - <BulkBlocking/> - </> - ); -} - -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) => { - console.error(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 ( - <div className="bulk"> - <h2>Import / Export <a onClick={resetBulk}>reset</a></h2> - <Bulk.TextArea - id="list" - name="Domains, one per line" - placeHolder={`google.com\nfacebook.com`} - /> - - <Bulk.TextArea - id="public_comment" - name="Public comment" - inputProps={disableInfoFields({rows: 3})} - /> - - <Bulk.TextArea - id="private_comment" - name="Private comment" - inputProps={disableInfoFields({rows: 3})} - /> - - <Bulk.Checkbox - id="obfuscate" - name="Obfuscate domains? " - inputProps={disableInfoFields()} - /> - - <div className="hidden"> - <Bulk.File - id="json" - fileType="application/json" - withPreview={false} - /> - </div> - - <div className="messagebutton"> - <div> - <button type="submit" onClick={importBlocks}>Import</button> - </div> - - <div> - <button type="submit" onClick={exportBlocks}>Export</button> - - <Bulk.Select id="exportType" name="Export type" options={ - <> - <option value="plain">One per line in text field</option> - <option value="json">JSON in text field</option> - <option value="json-download">JSON file download</option> - <option disabled value="csv">CSV in text field (glitch-soc)</option> - <option disabled value="csv-download">CSV file download (glitch-soc)</option> - </> - }/> - </div> - <br/> - <div> - {errorMsg.length > 0 && - <div className="error accent">{errorMsg}</div> - } - {statusMsg.length > 0 && - <div className="accent">{statusMsg}</div> - } - </div> - </div> - </div> - ); -} - -function InstancePageWrapped() { - /* We wrap the component to generate formFields with a setter depending on the domain - if formFields() is used inside the same component that is re-rendered with their state, - inputs get re-created on every change, causing them to lose focus, and bad performance - */ - let [_match, {domain}] = useRoute(`${base}/:domain`); - - if (domain == "view") { // from form field submission - let realDomain = (new URL(document.location)).searchParams.get("domain"); - if (realDomain == undefined) { - return <Redirect to={base}/>; - } else { - domain = realDomain; - } - } - - function alterDomain([key, val]) { - return adminActions.updateDomainBlockVal([domain, key, val]); - } - - const fields = formFields(alterDomain, (state) => state.admin.newInstanceBlocks[domain]); - - return <InstancePage domain={domain} Form={fields} />; -} - -function InstancePage({domain, Form}) { - const dispatch = Redux.useDispatch(); - const entry = Redux.useSelector(state => state.admin.newInstanceBlocks[domain]); - const [_location, setLocation] = useLocation(); - - React.useEffect(() => { - if (entry == undefined) { - dispatch(api.admin.getEditableDomainBlock(domain)); - } - }, [dispatch, domain, entry]); - - const [errorMsg, setError] = React.useState(""); - const [statusMsg, setStatus] = React.useState(""); - - if (entry == undefined) { - return <Loading/>; - } - - const updateBlock = submit( - () => dispatch(api.admin.updateDomainBlock(domain)), - {setStatus, setError} - ); - - const removeBlock = submit( - () => dispatch(api.admin.removeDomainBlock(domain)), - {setStatus, setError, startStatus: "Removing", successStatus: "Removed!", onSuccess: () => { - setLocation(base); - }} - ); - - return ( - <div> - <h1><BackButton to={base}/> Federation settings for: {domain}</h1> - {entry.new - ? "No stored block yet, you can add one below:" - : <b className="error">Editing domain blocks is not implemented yet, <a href="https://github.com/superseriousbusiness/gotosocial/issues/1198" target="_blank" rel="noopener noreferrer">check here for progress</a>.</b> - } - - <Form.TextArea - id="public_comment" - name="Public comment" - inputProps={{ - disabled: !entry.new - }} - /> - - <Form.TextArea - id="private_comment" - name="Private comment" - inputProps={{ - disabled: !entry.new - }} - /> - - <Form.Checkbox - id="obfuscate" - name="Obfuscate domain? " - inputProps={{ - disabled: !entry.new - }} - /> - - <div className="messagebutton"> - {entry.new - ? <button type="submit" onClick={updateBlock}>{entry.new ? "Add block" : "Save block"}</button> - : <button className="danger" onClick={removeBlock}>Remove block</button> - } - - {errorMsg.length > 0 && - <div className="error accent">{errorMsg}</div> - } - {statusMsg.length > 0 && - <div className="accent">{statusMsg}</div> - } - </div> - </div> - ); -}
\ No newline at end of file diff --git a/web/source/settings/admin/federation/detail.js b/web/source/settings/admin/federation/detail.js new file mode 100644 index 000000000..7324a42a5 --- /dev/null +++ b/web/source/settings/admin/federation/detail.js @@ -0,0 +1,146 @@ +/* + 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 { useRoute, Redirect } = require("wouter"); + +const query = require("../../lib/query"); + +const { useTextInput, useBoolInput } = require("../../lib/form"); + +const useFormSubmit = require("../../lib/form/submit"); + +const { TextInput, Checkbox, TextArea } = require("../../components/form/inputs"); + +const Loading = require("../../components/loading"); +const BackButton = require("../../components/back-button"); +const MutationButton = require("../../components/form/mutation-button"); + +module.exports = function InstanceDetail({ baseUrl }) { + const { data: blockedInstances = {}, isLoading } = query.useInstanceBlocksQuery(); + + let [_match, { domain }] = useRoute(`${baseUrl}/:domain`); + + if (domain == "view") { // from form field submission + domain = (new URL(document.location)).searchParams.get("domain"); + } + + const existingBlock = React.useMemo(() => { + return blockedInstances[domain]; + }, [blockedInstances, domain]); + + if (domain == undefined) { + return <Redirect to={baseUrl} />; + } + + let infoContent = null; + + if (isLoading) { + infoContent = <Loading />; + } else if (existingBlock == undefined) { + infoContent = <span>No stored block yet, you can add one below:</span>; + } else { + infoContent = ( + <div className="info"> + <i className="fa fa-fw fa-exclamation-triangle" aria-hidden="true"></i> + <b>Editing domain blocks isn't implemented yet, <a href="https://github.com/superseriousbusiness/gotosocial/issues/1198" target="_blank" rel="noopener noreferrer">check here for progress</a></b> + </div> + ); + } + + return ( + <div> + <h1><BackButton to={baseUrl} /> Federation settings for: {domain}</h1> + {infoContent} + <DomainBlockForm defaultDomain={domain} block={existingBlock} /> + </div> + ); +}; + +function DomainBlockForm({ defaultDomain, block = {} }) { + const isExistingBlock = block.domain != undefined; + + const disabledForm = isExistingBlock + ? { + disabled: true, + title: "Domain suspensions currently cannot be edited." + } + : {}; + + const form = { + domain: useTextInput("domain", { defaultValue: block.domain ?? defaultDomain }), + obfuscate: useBoolInput("obfuscate", { defaultValue: block.obfuscate }), + commentPrivate: useTextInput("private_comment", { defaultValue: block.private_comment }), + commentPublic: useTextInput("public_comment", { defaultValue: block.public_comment }) + }; + + const [submitForm, addResult] = useFormSubmit(form, query.useAddInstanceBlockMutation(), { changedOnly: false }); + + const [removeBlock, removeResult] = query.useRemoveInstanceBlockMutation({ fixedCacheKey: block.id }); + + return ( + <form onSubmit={submitForm}> + <TextInput + field={form.domain} + label="Domain" + placeholder="example.com" + {...disabledForm} + /> + + <Checkbox + field={form.obfuscate} + label="Obfuscate domain in public lists" + {...disabledForm} + /> + + <TextArea + field={form.commentPrivate} + label="Private comment" + rows={3} + {...disabledForm} + /> + + <TextArea + field={form.commentPublic} + label="Public comment" + rows={3} + {...disabledForm} + /> + + <MutationButton + label="Suspend" + result={addResult} + {...disabledForm} + /> + + { + isExistingBlock && + <MutationButton + type="button" + onClick={() => removeBlock(block.id)} + label="Remove" + result={removeResult} + className="button danger" + /> + } + + </form> + ); +}
\ No newline at end of file diff --git a/web/source/settings/admin/federation/import-export.js b/web/source/settings/admin/federation/import-export.js new file mode 100644 index 000000000..a11174b1f --- /dev/null +++ b/web/source/settings/admin/federation/import-export.js @@ -0,0 +1,307 @@ +/* + 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 { Switch, Route, Redirect, useLocation } = require("wouter"); + +const query = require("../../lib/query"); + +const { + useTextInput, + useBoolInput, + useRadioInput, + useCheckListInput +} = require("../../lib/form"); + +const useFormSubmit = require("../../lib/form/submit"); + +const { + TextInput, + TextArea, + Checkbox, + Select, + RadioGroup +} = require("../../components/form/inputs"); + +const CheckList = require("../../components/check-list"); +const MutationButton = require("../../components/form/mutation-button"); +const isValidDomain = require("is-valid-domain"); +const FormWithData = require("../../lib/form/form-with-data"); +const { Error } = require("../../components/error"); + +const baseUrl = "/settings/admin/federation/import-export"; + +module.exports = function ImportExport() { + const [updateFromFile, setUpdateFromFile] = React.useState(false); + const form = { + domains: useTextInput("domains"), + exportType: useTextInput("exportType", { defaultValue: "plain", dontReset: true }) + }; + + const [submitParse, parseResult] = useFormSubmit(form, query.useProcessDomainListMutation()); + const [submitExport, exportResult] = useFormSubmit(form, query.useExportDomainListMutation()); + + function fileChanged(e) { + const reader = new FileReader(); + reader.onload = function (read) { + form.domains.setter(read.target.result); + setUpdateFromFile(true); + }; + reader.readAsText(e.target.files[0]); + } + + React.useEffect(() => { + if (exportResult.isSuccess) { + form.domains.setter(exportResult.data); + } + /* eslint-disable-next-line react-hooks/exhaustive-deps */ + }, [exportResult]); + + const [_location, setLocation] = useLocation(); + + if (updateFromFile) { + setUpdateFromFile(false); + submitParse(); + } + + return ( + <Switch> + <Route path={`${baseUrl}/list`}> + {!parseResult.isSuccess && <Redirect to={baseUrl} />} + + <h1> + <span className="button" onClick={() => { + parseResult.reset(); + setLocation(baseUrl); + }}> + < back + </span> Confirm import: + </h1> + <FormWithData + dataQuery={query.useInstanceBlocksQuery} + DataForm={ImportList} + list={parseResult.data} + /> + </Route> + + <Route> + {parseResult.isSuccess && <Redirect to={`${baseUrl}/list`} />} + <h2>Import / Export suspended domains</h2> + + <div> + <form onSubmit={submitParse}> + <TextArea + field={form.domains} + label="Domains, one per line (plaintext) or JSON" + placeholder={`google.com\nfacebook.com`} + rows={8} + /> + + <div className="row"> + <MutationButton label="Import" result={parseResult} showError={false} /> + <button type="button" className="with-padding"> + <label> + Import file + <input className="hidden" type="file" onChange={fileChanged} accept="application/json,text/plain" /> + </label> + </button> + </div> + </form> + <form onSubmit={submitExport}> + <div className="row"> + <MutationButton name="export" label="Export" result={exportResult} showError={false} /> + <MutationButton name="export-file" label="Export file" result={exportResult} showError={false} /> + <Select + field={form.exportType} + options={<> + <option value="plain">Text</option> + <option value="json">JSON</option> + </>} + /> + </div> + </form> + {parseResult.error && <Error error={parseResult.error} />} + {exportResult.error && <Error error={exportResult.error} />} + </div> + </Route> + </Switch> + ); +}; + +function ImportList({ list, data: blockedInstances }) { + const hasComment = React.useMemo(() => { + let hasPublic = false; + let hasPrivate = false; + + list.some((entry) => { + if (entry.public_comment?.length > 0) { + hasPublic = true; + } + + if (entry.private_comment?.length > 0) { + hasPrivate = true; + } + + return hasPublic && hasPrivate; + }); + + if (hasPublic && hasPrivate) { + return { both: true }; + } else if (hasPublic) { + return { type: "public_comment" }; + } else if (hasPrivate) { + return { type: "private_comment" }; + } else { + return {}; + } + }, [list]); + + const showComment = useTextInput("showComment", { defaultValue: hasComment.type ?? "public_comment" }); + let commentName = ""; + if (showComment.value == "public_comment") { commentName = "Public comment"; } + if (showComment.value == "private_comment") { commentName = "Private comment"; } + + const form = { + domains: useCheckListInput("domains", { + entries: list, + uniqueKey: "domain" + }), + obfuscate: useBoolInput("obfuscate"), + privateComment: useTextInput("private_comment", { + defaultValue: `Imported on ${new Date().toLocaleString()}` + }), + privateCommentBehavior: useRadioInput("private_comment_behavior", { + defaultValue: "append", + options: { + append: "Append to", + replace: "Replace" + } + }), + publicComment: useTextInput("public_comment"), + publicCommentBehavior: useRadioInput("public_comment_behavior", { + defaultValue: "append", + options: { + append: "Append to", + replace: "Replace" + } + }), + }; + + const [importDomains, importResult] = useFormSubmit(form, query.useImportDomainListMutation(), { changedOnly: false }); + + return ( + <> + <form onSubmit={importDomains} className="suspend-import-list"> + <span>{list.length} domain{list.length != 1 ? "s" : ""} in this list</span> + + {hasComment.both && + <Select field={showComment} options={ + <> + <option value="public_comment">Show public comments</option> + <option value="private_comment">Show private comments</option> + </> + } /> + } + + <CheckList + field={form.domains} + Component={DomainEntry} + header={ + <> + <b>Domain</b> + <b></b> + <b>{commentName}</b> + </> + } + blockedInstances={blockedInstances} + commentType={showComment.value} + /> + + <TextArea + field={form.privateComment} + label="Private comment" + rows={3} + /> + <RadioGroup + field={form.privateCommentBehavior} + label="imported private comment" + /> + + <TextArea + field={form.publicComment} + label="Public comment" + rows={3} + /> + <RadioGroup + field={form.publicCommentBehavior} + label="imported public comment" + /> + + <Checkbox + field={form.obfuscate} + label="Obfuscate domains in public lists" + /> + + <MutationButton label="Import" result={importResult} /> + </form> + </> + ); +} + +function DomainEntry({ entry, onChange, blockedInstances, commentType }) { + const domainField = useTextInput("domain", { + defaultValue: entry.domain, + validator: (value) => { + return (entry.checked && !isValidDomain(value, { wildcard: true, allowUnicode: true })) + ? "Invalid domain" + : ""; + } + }); + + React.useEffect(() => { + onChange({ valid: domainField.valid }); + /* eslint-disable-next-line react-hooks/exhaustive-deps */ + }, [domainField.valid]); + + let icon = null; + + if (blockedInstances[domainField.value] != undefined) { + icon = ( + <> + <i className="fa fa-history already-blocked" aria-hidden="true" title="Domain block already exists"></i> + <span className="sr-only">Domain block already exists.</span> + </> + ); + } + + return ( + <> + <TextInput + field={domainField} + onChange={(e) => { + domainField.onChange(e); + onChange({ domain: e.target.value, checked: true }); + }} + /> + <span id="icon">{icon}</span> + <p>{entry[commentType]}</p> + </> + ); +}
\ No newline at end of file diff --git a/web/source/settings/admin/federation/index.js b/web/source/settings/admin/federation/index.js new file mode 100644 index 000000000..beaa6e1c5 --- /dev/null +++ b/web/source/settings/admin/federation/index.js @@ -0,0 +1,44 @@ +/* + 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 { Switch, Route } = require("wouter"); + +const baseUrl = `/settings/admin/federation`; + +const InstanceOverview = require("./overview"); +const InstanceDetail = require("./detail"); +const InstanceImportExport = require("./import-export"); + +module.exports = function Federation({ }) { + return ( + <Switch> + <Route path={`${baseUrl}/import-export/:list?`}> + <InstanceImportExport /> + </Route> + + <Route path={`${baseUrl}/:domain`}> + <InstanceDetail baseUrl={baseUrl} /> + </Route> + + <InstanceOverview baseUrl={baseUrl} /> + </Switch> + ); +};
\ No newline at end of file diff --git a/web/source/settings/admin/federation/overview.js b/web/source/settings/admin/federation/overview.js new file mode 100644 index 000000000..b655423a4 --- /dev/null +++ b/web/source/settings/admin/federation/overview.js @@ -0,0 +1,100 @@ +/* + 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 { Link, useLocation } = require("wouter"); +const { matchSorter } = require("match-sorter"); + +const { useTextInput } = require("../../lib/form"); + +const { TextInput } = require("../../components/form/inputs"); + +const query = require("../../lib/query"); + +const Loading = require("../../components/loading"); + +module.exports = function InstanceOverview({ baseUrl }) { + const { data: blockedInstances = [], isLoading } = query.useInstanceBlocksQuery(); + + const [_location, setLocation] = useLocation(); + + const filterField = useTextInput("filter"); + const filter = filterField.value; + + const blockedInstancesList = React.useMemo(() => { + return Object.values(blockedInstances); + }, [blockedInstances]); + + const filteredInstances = React.useMemo(() => { + return matchSorter(blockedInstancesList, filter, { keys: ["domain"] }); + }, [blockedInstancesList, filter]); + + let filtered = blockedInstancesList.length - filteredInstances.length; + + function filterFormSubmit(e) { + e.preventDefault(); + setLocation(`${baseUrl}/${filter}`); + } + + if (isLoading) { + return <Loading />; + } + + return ( + <> + <h1>Federation</h1> + + <div className="instance-list"> + <h2>Suspended instances</h2> + <p> + Suspending a domain blocks all current and future accounts on that instance. Stored content will be removed, + and no more data is sent to the remote server.<br /> + This extends to all subdomains as well, so blocking 'example.com' also includes 'social.example.com'. + </p> + <form className="filter" role="search" onSubmit={filterFormSubmit}> + <TextInput field={filterField} placeholder="example.com" label="Search or add domain suspension" /> + <Link to={`${baseUrl}/${filter}`}><a className="button">Suspend</a></Link> + </form> + <div> + <span> + {blockedInstancesList.length} blocked instance{blockedInstancesList.length != 1 ? "s" : ""} {filtered > 0 && `(${filtered} filtered by search)`} + </span> + <div className="list scrolling"> + {filteredInstances.map((entry) => { + return ( + <Link key={entry.domain} to={`${baseUrl}/${entry.domain}`}> + <a className="entry nounderline"> + <span id="domain"> + {entry.domain} + </span> + <span id="date"> + {new Date(entry.created_at).toLocaleString()} + </span> + </a> + </Link> + ); + })} + </div> + </div> + </div> + <Link to={`${baseUrl}/import-export`}><a>Or use the bulk import/export interface</a></Link> + </> + ); +};
\ No newline at end of file diff --git a/web/source/settings/admin/settings.js b/web/source/settings/admin/settings.js index c3b8e9d91..c0a9eabbe 100644 --- a/web/source/settings/admin/settings.js +++ b/web/source/settings/admin/settings.js @@ -19,88 +19,105 @@ "use strict"; const React = require("react"); -const Redux = require("react-redux"); -const Submit = require("../components/submit"); +const query = require("../lib/query"); -const api = require("../lib/api"); -const submit = require("../lib/submit"); +const { + useTextInput, + useFileInput +} = require("../lib/form"); -const adminActions = require("../redux/reducers/instances").actions; +const useFormSubmit = require("../lib/form/submit"); const { TextInput, TextArea, - File -} = require("../components/form-fields").formFields(adminActions.setAdminSettingsVal, (state) => state.instances.adminSettings); - -module.exports = function AdminSettings() { - const dispatch = Redux.useDispatch(); - const instance = Redux.useSelector(state => state.instances.adminSettings); + FileInput +} = require("../components/form/inputs"); - const [errorMsg, setError] = React.useState(""); - const [statusMsg, setStatus] = React.useState(""); +const FormWithData = require("../lib/form/form-with-data"); +const MutationButton = require("../components/form/mutation-button"); - const updateSettings = submit( - () => dispatch(api.admin.updateInstance()), - {setStatus, setError} +module.exports = function AdminSettings() { + return ( + <FormWithData + dataQuery={query.useInstanceQuery} + DataForm={AdminSettingsForm} + /> ); +}; + +function AdminSettingsForm({ data: instance }) { + const form = { + title: useTextInput("title", { defaultValue: instance.title }), + thumbnail: useFileInput("thumbnail", { withPreview: true }), + thumbnailDesc: useTextInput("thumbnail_description", { defaultValue: instance.thumbnail_description }), + shortDesc: useTextInput("short_description", { defaultValue: instance.short_description }), + description: useTextInput("description", { defaultValue: instance.description }), + contactUser: useTextInput("contact_username", { defaultValue: instance.contact_account?.username }), + contactEmail: useTextInput("contact_email", { defaultValue: instance.email }), + terms: useTextInput("terms", { defaultValue: instance.terms }) + }; + + const [submitForm, result] = useFormSubmit(form, query.useUpdateInstanceMutation()); return ( - <div> + <form onSubmit={submitForm}> <h1>Instance Settings</h1> <TextInput - id="title" - name="Title" - placeHolder="My GoToSocial instance" + field={form.title} + label="Title" + placeholder="My GoToSocial instance" /> <div className="file-upload"> <h3>Instance thumbnail</h3> <div> - <img className="preview avatar" src={instance.thumbnail} alt={instance.thumbnail ? `Thumbnail image for the instance` : "No instance thumbnail image set"} /> - <File - id="thumbnail" - fileType="image/*" + <img className="preview avatar" src={form.thumbnail.previewValue ?? instance.thumbnail} alt={form.thumbnailDesc.value ?? (instance.thumbnail ? `Thumbnail image for the instance` : "No instance thumbnail image set")} /> + <FileInput + field={form.thumbnail} + accept="image/*" /> </div> </div> <TextInput - id="thumbnail_description" - name="Instance thumbnail description" - placeHolder="A cute little picture of a smiling sloth." + field={form.thumbnailDesc} + label="Instance thumbnail description" + placeholder="A cute drawing of a smiling sloth." /> <TextArea - id="short_description" - name="Short description" - placeHolder="A small testing instance for the GoToSocial alpha." + field={form.shortDesc} + label="Short description" + placeholder="A small testing instance for the GoToSocial alpha software." /> + <TextArea - id="description" - name="Full description" - placeHolder="A small testing instance for the GoToSocial alpha." + field={form.description} + label="Full description" + placeholder="A small testing instance for the GoToSocial alpha software. Just trying it out, my main instance is https://example.com" /> <TextInput - id="contact_account.username" - name="Contact user (local account username)" - placeHolder="admin" + field={form.contactUser} + label="Contact user (local account username)" + placeholder="admin" /> + <TextInput - id="email" - name="Contact email" - placeHolder="admin@example.com" + field={form.contactEmail} + label="Contact email" + placeholder="admin@example.com" /> <TextArea - id="terms" - name="Terms & Conditions" - placeHolder="" + field={form.terms} + label="Terms & Conditions" + placeholder="" /> - <Submit onClick={updateSettings} label="Save" errorMsg={errorMsg} statusMsg={statusMsg} /> - </div> + <MutationButton label="Save" result={result} /> + </form> ); -};
\ No newline at end of file +}
\ No newline at end of file |