gtsocial-umbx

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

commit 9b139b632098e6741b10fa87ff6224dcb5045947
parent 974ec80a206f4e797e88dbb3a429b53667e327c5
Author: f0x52 <f0x@cthu.lu>
Date:   Wed, 18 Jan 2023 14:45:14 +0100

[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:
Mweb/source/.eslintrc.js | 24+++++++++++++++++++++++-
Aweb/source/.license-header.js | 17+++++++++++++++++
Mweb/source/css/_colors.css | 12++++++++++--
Mweb/source/css/base.css | 8++++++--
Mweb/source/index.js | 3++-
Mweb/source/package.json | 4++--
Mweb/source/settings/admin/actions.js | 41+++++++++++++++++++++--------------------
Mweb/source/settings/admin/emoji/category-select.jsx | 42++++++++++++++++++++++--------------------
Mweb/source/settings/admin/emoji/local/detail.js | 167+++++++++++++++++++++++++++++++++----------------------------------------------
Mweb/source/settings/admin/emoji/local/index.js | 16+++++++---------
Mweb/source/settings/admin/emoji/local/new-emoji.js | 146+++++++++++++++++++++++--------------------------------------------------------
Mweb/source/settings/admin/emoji/local/overview.js | 51++++++++++++++++++++++++---------------------------
Aweb/source/settings/admin/emoji/local/use-shortcode.js | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mweb/source/settings/admin/emoji/remote/index.js | 6+++---
Mweb/source/settings/admin/emoji/remote/parse-from-toot.js | 322+++++++++++++++++++++++++------------------------------------------------------
Dweb/source/settings/admin/federation.js | 395-------------------------------------------------------------------------------
Aweb/source/settings/admin/federation/detail.js | 147+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/source/settings/admin/federation/import-export.js | 308+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/source/settings/admin/federation/index.js | 45+++++++++++++++++++++++++++++++++++++++++++++
Aweb/source/settings/admin/federation/overview.js | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mweb/source/settings/admin/settings.js | 109++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Aweb/source/settings/components/authorization/index.jsx | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/source/settings/components/authorization/login.jsx | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mweb/source/settings/components/back-button.jsx | 2+-
Aweb/source/settings/components/check-list.jsx | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mweb/source/settings/components/combo-box.jsx | 8++++----
Mweb/source/settings/components/error.jsx | 47+++++++++++++++++++++++++++++++++++++++++++----
Mweb/source/settings/components/fake-profile.jsx | 17+++++++----------
Mweb/source/settings/components/fake-toot.jsx | 13+++++++++----
Dweb/source/settings/components/form-fields.jsx | 168-------------------------------------------------------------------------------
Dweb/source/settings/components/form/combobox.jsx | 42------------------------------------------
Dweb/source/settings/components/form/file.jsx | 79-------------------------------------------------------------------------------
Dweb/source/settings/components/form/index.js | 38--------------------------------------
Aweb/source/settings/components/form/inputs.jsx | 142+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/source/settings/components/form/mutation-button.jsx | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Dweb/source/settings/components/form/text.jsx | 57---------------------------------------------------------
Mweb/source/settings/components/loading.jsx | 2+-
Dweb/source/settings/components/login.jsx | 103-------------------------------------------------------------------------------
Dweb/source/settings/components/mutation-button.jsx | 43-------------------------------------------
Mweb/source/settings/components/nav-button.jsx | 2+-
Dweb/source/settings/components/submit.jsx | 35-----------------------------------
Mweb/source/settings/index.js | 146+++++++++++++++----------------------------------------------------------------
Dweb/source/settings/lib/api/admin.js | 169-------------------------------------------------------------------------------
Dweb/source/settings/lib/api/index.js | 194-------------------------------------------------------------------------------
Dweb/source/settings/lib/api/oauth.js | 128-------------------------------------------------------------------------------
Dweb/source/settings/lib/api/user.js | 68--------------------------------------------------------------------
Dweb/source/settings/lib/errors.js | 28----------------------------
Aweb/source/settings/lib/form/bool.jsx | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/source/settings/lib/form/check-list.jsx | 148+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/source/settings/lib/form/combo-box.jsx | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/source/settings/lib/form/file.jsx | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/source/settings/lib/form/form-with-data.jsx | 40++++++++++++++++++++++++++++++++++++++++
Aweb/source/settings/lib/form/index.js | 47+++++++++++++++++++++++++++++++++++++++++++++++
Aweb/source/settings/lib/form/radio.jsx | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/source/settings/lib/form/submit.js | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/source/settings/lib/form/text.jsx | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mweb/source/settings/lib/get-views.js | 4++--
Aweb/source/settings/lib/query/admin/custom-emoji.js | 196+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/source/settings/lib/query/admin/import-export.js | 213+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/source/settings/lib/query/admin/index.js | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mweb/source/settings/lib/query/base.js | 60++++++++++++++++++++++++++++++++++++++++++++----------------
Dweb/source/settings/lib/query/custom-emoji.js | 181-------------------------------------------------------------------------------
Mweb/source/settings/lib/query/index.js | 28+++++++++++++++-------------
Aweb/source/settings/lib/query/lib.js | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/source/settings/lib/query/oauth.js | 159+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/source/settings/lib/query/user.js | 45+++++++++++++++++++++++++++++++++++++++++++++
Dweb/source/settings/lib/submit.js | 49-------------------------------------------------
Mweb/source/settings/redux/index.js | 34+++++++++++++++-------------------
Aweb/source/settings/redux/oauth.js | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Dweb/source/settings/redux/reducers/admin.js | 100-------------------------------------------------------------------------------
Dweb/source/settings/redux/reducers/instances.js | 43-------------------------------------------
Dweb/source/settings/redux/reducers/oauth.js | 53-----------------------------------------------------
Dweb/source/settings/redux/reducers/temporary.js | 33---------------------------------
Dweb/source/settings/redux/reducers/user.js | 51---------------------------------------------------
Mweb/source/settings/style.css | 223+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Mweb/source/settings/user/profile.js | 126+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Mweb/source/settings/user/settings.js | 147++++++++++++++++++++++++++++++++++++++++---------------------------------------
Mweb/source/yarn.lock | 17++++++++++++-----
78 files changed, 3447 insertions(+), 2975 deletions(-)

diff --git a/web/source/.eslintrc.js b/web/source/.eslintrc.js @@ -1,5 +1,27 @@ +/* + 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"; module.exports = { - "extends": ["@joepie91/eslint-config/react"] + "extends": ["@joepie91/eslint-config/react"], + "plugins": ["license-header"], + "rules": { + "license-header/header": ["error", ".license-header.js"] + } }; \ No newline at end of file diff --git a/web/source/.license-header.js b/web/source/.license-header.js @@ -0,0 +1,17 @@ +/* + 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/>. +*/ diff --git a/web/source/css/_colors.css b/web/source/css/_colors.css @@ -47,7 +47,13 @@ $blue3: #89caff; /* hover/selected accent to $blue2, can be used with $gray1 (7. $error1: #860000; /* Error border/foreground text, can be used with $error2 (5.0), $white1 (10), $white2 (5.1) */ $error2: #ff9796; /* Error background text, can be used with $error1 (5.0), $gray1 (6.6), $gray2 (5.3), $gray3 (4.8) */ $error3: #dd2c2c; /* Error button background text, can be used with $white1 (4.51) */ -$error-link: #185F8C; /* Error link text, can be used with $error2 (5.54) */ +$error-link: #01318C; /* Error link text, can be used with $error2 (5.56) */ + +$green1: #94E749; /* Green for positive/confirmation, similar contrast (luminance) as $blue2 */ + +$info-fg: $gray1; +$info-bg: #b3ddff; +$info-link: $error-link; $fg: $white1; $bg: $gray1; @@ -92,6 +98,7 @@ $avatar-border: $orange2; $input-bg: $gray4; $input-disabled-bg: $gray2; $input-border: $blue1; +$input-error-border: $error3; $input-focus-border: $blue3; $settings-nav-bg: $bg-accent; @@ -107,5 +114,6 @@ $settings-nav-bg-active: $gray2; $error-fg: $error1; $error-bg: $error2; -$settings-entry-bg: $gray3; +$settings-entry-bg: $gray2; +$settings-entry-alternate-bg: $gray3; $settings-entry-hover-bg: $gray4; \ No newline at end of file diff --git a/web/source/css/base.css b/web/source/css/base.css @@ -311,12 +311,16 @@ input, select, textarea, .input { font-size: 1rem; padding: 0.3rem; - &:focus { + &:focus, &:active { border-color: $input-focus-border; } + &:invalid { + border-color: $input-error-border; + } + &:disabled { - background: $input-disabled-bg; + background: transparent; } } diff --git a/web/source/index.js b/web/source/index.js @@ -32,7 +32,7 @@ const prodCfg = { global: true, exts: ".js" }], - ["@browserify/envify", {global: true}] + ["@browserify/envify", { global: true }] ] }; @@ -66,6 +66,7 @@ skulk({ ], }, settings: { + debug: false, entryFile: "settings", outputFile: "settings.js", prodCfg: prodCfg, diff --git a/web/source/package.json b/web/source/package.json @@ -6,7 +6,7 @@ "author": "f0x", "license": "AGPL-3.0", "scripts": { - "lint": "eslint .", + "lint": "eslint . --ext .js,.jsx", "build": "node index.js", "dev": "NODE_ENV=development node index.js" }, @@ -14,7 +14,6 @@ "@reduxjs/toolkit": "^1.8.6", "ariakit": "^2.0.0-next.41", "bluebird": "^3.7.2", - "dotty": "^0.1.2", "is-valid-domain": "^0.1.6", "js-file-download": "^0.4.12", "langs": "^2.0.0", @@ -44,6 +43,7 @@ "babelify": "^10.0.0", "css-extract": "^2.0.0", "eslint": "^8.26.0", + "eslint-plugin-license-header": "^0.6.0", "eslint-plugin-react": "^7.31.10", "eslint-plugin-react-hooks": "^4.6.0", "factor-bundle": "^2.5.0", diff --git 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 @@ -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 @@ -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>&lt; 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&apos;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 @@ -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 @@ -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&apos;t it cool? </FakeToot> - <form onSubmit={uploadEmoji} className="form-flex"> - <div className="form-field file"> - <label className="file-input button" htmlFor="image"> - Browse - </label> - {imageInfo} - <input - className="hidden" - type="file" - id="image" - name="Image" - accept="image/png,image/gif" - onChange={onFileChange} - /> - </div> - - <div className="form-field text"> - <label htmlFor="shortcode"> - Shortcode, must be unique among the instance's local emoji - </label> - <input - type="text" - id="shortcode" - name="Shortcode" - ref={shortcodeRef} - onChange={onShortcodeChange} - value={shortcode} - /> - </div> + <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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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); + }}> + &lt; 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 @@ -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 @@ -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 @@ -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 diff --git a/web/source/settings/components/authorization/index.jsx b/web/source/settings/components/authorization/index.jsx @@ -0,0 +1,76 @@ +/* + 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 Redux = require("react-redux"); + +const query = require("../../lib/query"); + +const Login = require("./login"); +const Loading = require("../loading"); +const { Error } = require("../error"); + +module.exports = function Authorization({ App }) { + const loginState = Redux.useSelector((state) => state.oauth.loginState); + const [hasStoredLogin] = React.useState(loginState != "none" && loginState != "logout"); + + const { isLoading, isSuccess, data: account, error } = query.useVerifyCredentialsQuery(undefined, { + skip: loginState == "none" || loginState == "logout" + }); + + let showLogin = true; + let content = null; + + if (isLoading && hasStoredLogin) { + showLogin = false; + + let loadingInfo; + if (loginState == "callback") { + loadingInfo = "Processing OAUTH callback."; + } else if (loginState == "login") { + loadingInfo = "Verifying stored login."; + } + + content = ( + <div> + <Loading /> {loadingInfo} + </div> + ); + } else if (error != undefined) { + content = ( + <div> + <Error error={error} /> + You can attempt logging in again below: + </div> + ); + } + + if (loginState == "login" && isSuccess) { + return <App account={account} />; + } else { + return ( + <section className="oauth"> + <h1>GoToSocial Settings</h1> + {content} + {showLogin && <Login />} + </section> + ); + } +}; +\ No newline at end of file diff --git a/web/source/settings/components/authorization/login.jsx b/web/source/settings/components/authorization/login.jsx @@ -0,0 +1,67 @@ +/* + 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, useValue } = require("../../lib/form"); +const useFormSubmit = require("../../lib/form/submit"); +const { TextInput } = require("../form/inputs"); +const MutationButton = require("../form/mutation-button"); +const Loading = require("../loading"); + +module.exports = function Login({ }) { + const form = { + instance: useTextInput("instance", { + defaultValue: window.location.origin + }), + scopes: useValue("scopes", "user admin") + }; + + const [formSubmit, result] = useFormSubmit( + form, + query.useAuthorizeFlowMutation(), + { changedOnly: false } + ); + + if (result.isLoading) { + return ( + <div> + <Loading /> Checking instance. + </div> + ); + } else if (result.isSuccess) { + return ( + <div> + <Loading /> Redirecting to instance authorization page. + </div> + ); + } + + return ( + <form onSubmit={formSubmit}> + <TextInput + field={form.instance} + label="Instance" + /> + <MutationButton label="Login" result={result} /> + </form> + ); +}; +\ No newline at end of file diff --git a/web/source/settings/components/back-button.jsx b/web/source/settings/components/back-button.jsx @@ -21,7 +21,7 @@ const React = require("react"); const { Link } = require("wouter"); -module.exports = function BackButton({to}) { +module.exports = function BackButton({ to }) { return ( <Link to={to}> <a className="button">&lt; back</a> diff --git a/web/source/settings/components/check-list.jsx b/web/source/settings/components/check-list.jsx @@ -0,0 +1,58 @@ +/* + 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"); + +module.exports = function CheckList({ field, Component, header = " All", ...componentProps }) { + return ( + <div className="checkbox-list list"> + <label className="header"> + <input + ref={field.toggleAll.ref} + type="checkbox" + onChange={field.toggleAll.onChange} + checked={field.toggleAll.value === 1} + /> {header} + </label> + {Object.values(field.value).map((entry) => ( + <CheckListEntry + key={entry.key} + onChange={(value) => field.onChange(entry.key, value)} + entry={entry} + Component={Component} + componentProps={componentProps} + /> + ))} + </div> + ); +}; + +function CheckListEntry({ entry, onChange, Component, componentProps }) { + return ( + <label className="entry"> + <input + type="checkbox" + onChange={(e) => onChange({ checked: e.target.checked })} + checked={entry.checked} + /> + <Component entry={entry} onChange={onChange} {...componentProps} /> + </label> + ); +} +\ No newline at end of file diff --git a/web/source/settings/components/combo-box.jsx b/web/source/settings/components/combo-box.jsx @@ -26,21 +26,21 @@ const { ComboboxPopover, } = require("ariakit/combobox"); -module.exports = function ComboBox({state, items, label, placeHolder, children}) { +module.exports = function ComboBox({ field, items, label, children, ...inputProps }) { return ( <div className="form-field combobox-wrapper"> <label> {label} <div className="row"> <Combobox - state={state} - placeholder={placeHolder} + state={field.state} className="combobox input" + {...inputProps} /> {children} </div> </label> - <ComboboxPopover state={state} className="popover"> + <ComboboxPopover state={field.state} className="popover"> {items.map(([key, value]) => ( <ComboboxItem className="combobox-item" key={key} value={key}> {value} diff --git a/web/source/settings/components/error.jsx b/web/source/settings/components/error.jsx @@ -20,7 +20,7 @@ const React = require("react"); -module.exports = function ErrorFallback({error, resetErrorBoundary}) { +function ErrorFallback({ error, resetErrorBoundary }) { return ( <div className="error"> <p> @@ -28,7 +28,7 @@ module.exports = function ErrorFallback({error, resetErrorBoundary}) { <a href="https://github.com/superseriousbusiness/gotosocial/issues">GoToSocial issue tracker</a> {" or "} <a href="https://matrix.to/#/#gotosocial-help:superseriousbusiness.org">Matrix support room</a>. - <br/>Include the details below: + <br />Include the details below: </p> <pre> {error.name}: {error.message} @@ -41,4 +41,43 @@ module.exports = function ErrorFallback({error, resetErrorBoundary}) { </p> </div> ); -}; -\ No newline at end of file +} + +function Error({ error }) { + /* eslint-disable-next-line no-console */ + console.error("Rendering error:", error); + let message; + + if (error.data != undefined) { // RTK Query error with data + if (error.status) { + message = (<> + <b>{error.status}:</b> {error.data.error} + {error.data.error_description && + <p> + {error.data.error_description} + </p> + } + </>); + } else { + message = error.data.error; + } + } else if (error.name != undefined || error.type != undefined) { // JS error + message = (<> + <b>{error.type && error.name}:</b> {error.message} + </>); + } else if (error.status && typeof error.error == "string") { + message = (<> + <b>{error.status}:</b> {error.error} + </>); + } else { + message = error.message ?? error; + } + + return ( + <div className="error"> + {message} + </div> + ); +} + +module.exports = { ErrorFallback, Error }; +\ No newline at end of file diff --git a/web/source/settings/components/fake-profile.jsx b/web/source/settings/components/fake-profile.jsx @@ -19,24 +19,21 @@ "use strict"; const React = require("react"); -const Redux = require("react-redux"); - -module.exports = function FakeProfile({}) { - const account = Redux.useSelector(state => state.user.profile); +module.exports = function FakeProfile({ avatar, header, display_name, username, role }) { return ( // Keep in sync with web/template/profile.tmpl <div className="profile"> <div className="headerimage"> - <img className="headerpreview" src={account.header} alt={account.header ? `header image for ${account.username}` : "None set"} /> + <img className="headerpreview" src={header} alt={header ? `header image for ${username}` : "None set"} /> </div> <div className="basic"> <div id="profile-basic-filler2"></div> - <span className="avatar"><img className="avatarpreview" src={account.avatar} alt={account.avatar ? `avatar image for ${account.username}` : "None set"} /></span> - <div className="displayname">{account.display_name.trim().length > 0 ? account.display_name : account.username}</div> + <span className="avatar"><img className="avatarpreview" src={avatar} alt={avatar ? `avatar image for ${username}` : "None set"} /></span> + <div className="displayname">{display_name.trim().length > 0 ? display_name : username}</div> <div className="usernamecontainer"> - <div className="username"><span>@{account.username}</span></div> - {(account.role && account.role != "user") && - <div className={`role ${account.role}`}>{account.role}</div> + <div className="username"><span>@{username}</span></div> + {(role && role != "user") && + <div className={`role ${role}`}>{role}</div> } </div> </div> diff --git a/web/source/settings/components/fake-toot.jsx b/web/source/settings/components/fake-toot.jsx @@ -19,16 +19,21 @@ "use strict"; const React = require("react"); -const Redux = require("react-redux"); -module.exports = function FakeToot({children}) { - const account = Redux.useSelector((state) => state.user.profile); +const query = require("../lib/query"); + +module.exports = function FakeToot({ children }) { + const { data: account = { + avatar: "/assets/default_avatars/GoToSocial_icon1.png", + display_name: "", + username: "" + } } = query.useVerifyCredentialsQuery(); return ( <div className="toot expanded"> <div className="contentgrid"> <span className="avatar"> - <img src={account.avatar} alt=""/> + <img src={account.avatar} alt="" /> </span> <span className="displayname">{account.display_name.trim().length > 0 ? account.display_name : account.username}</span> <span className="username">@{account.username}</span> diff --git a/web/source/settings/components/form-fields.jsx b/web/source/settings/components/form-fields.jsx @@ -1,167 +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 React = require("react"); -const Redux = require("react-redux"); -const d = require("dotty"); -const prettierBytes = require("prettier-bytes"); - -function eventListeners(dispatch, setter, obj) { - return { - onTextChange: function (key) { - return function (e) { - dispatch(setter([key, e.target.value])); - }; - }, - - onCheckChange: function (key) { - return function (e) { - dispatch(setter([key, e.target.checked])); - }; - }, - - onFileChange: function (key, withPreview) { - return function (e) { - let file = e.target.files[0]; - if (withPreview) { - let old = d.get(obj, key); - if (old != undefined) { - URL.revokeObjectURL(old); // no error revoking a non-Object URL as provided by instance - } - let objectURL = URL.createObjectURL(file); - dispatch(setter([key, objectURL])); - } - dispatch(setter([`${key}File`, file])); - }; - } - }; -} - -function get(state, id, defaultVal) { - let value; - if (id.includes(".")) { - value = d.get(state, id); - } else { - value = state[id]; - } - if (value == undefined) { - value = defaultVal; - } - return value; -} - -// function removeFile(name) { -// return function(e) { -// e.preventDefault(); -// dispatch(user.setProfileVal([name, ""])); -// dispatch(user.setProfileVal([`${name}File`, ""])); -// }; -// } - -module.exports = { - formFields: function formFields(setter, selector) { - function FormField({ - type, id, name, className="", placeHolder="", fileType="", children=null, - options=null, inputProps={}, withPreview=true, showSize=false, maxSize=Infinity - }) { - const dispatch = Redux.useDispatch(); - let state = Redux.useSelector(selector); - let { - onTextChange, - onCheckChange, - onFileChange - } = eventListeners(dispatch, setter, state); - - let field; - let defaultLabel = true; - if (type == "text") { - field = <input type="text" id={id} value={get(state, id, "")} placeholder={placeHolder} className={className} onChange={onTextChange(id)} {...inputProps}/>; - } else if (type == "textarea") { - field = <textarea type="text" id={id} value={get(state, id, "")} placeholder={placeHolder} className={className} onChange={onTextChange(id)} rows={8} {...inputProps}/>; - } else if (type == "checkbox") { - field = <input type="checkbox" id={id} checked={get(state, id, false)} className={className} onChange={onCheckChange(id)} {...inputProps}/>; - } else if (type == "select") { - field = ( - <select id={id} value={get(state, id, "")} className={className} onChange={onTextChange(id)} {...inputProps}> - {options} - </select> - ); - } else if (type == "file") { - defaultLabel = false; - let file = get(state, `${id}File`); - - let size = null; - if (showSize && file) { - size = `(${prettierBytes(file.size)})`; - - if (file.size > maxSize) { - size = <span className="error-text">{size}</span>; - } - } - - field = ( - <> - <label htmlFor={id} className="file-input button">Browse</label> - <span className="form-info"> - {file ? file.name : "no file selected"} {size} - </span> - {/* <a onClick={removeFile("header")}>remove</a> */} - <input className="hidden" id={id} type="file" accept={fileType} onChange={onFileChange(id, withPreview)} {...inputProps}/> - </> - ); - } else { - defaultLabel = false; - field = `unsupported FormField ${type}, this is a developer error`; - } - - let label = <label htmlFor={id}>{name}</label>; - return ( - <div className={`form-field ${type}`}> - {defaultLabel ? label : null} {field} - {children} - </div> - ); - } - - return { - TextInput: function(props) { - return <FormField type="text" {...props} />; - }, - - TextArea: function(props) { - return <FormField type="textarea" {...props} />; - }, - - Checkbox: function(props) { - return <FormField type="checkbox" {...props} />; - }, - - Select: function(props) { - return <FormField type="select" {...props} />; - }, - - File: function(props) { - return <FormField type="file" {...props} />; - }, - }; - }, - - eventListeners -}; -\ No newline at end of file diff --git a/web/source/settings/components/form/combobox.jsx b/web/source/settings/components/form/combobox.jsx @@ -1,41 +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 { useComboboxState } = require("ariakit/combobox"); - -module.exports = function useComboBoxInput({name, Name}, {validator, defaultValue} = {}) { - const state = useComboboxState({ - defaultValue, - gutter: 0, - sameWidth: true - }); - - function reset() { - state.setValue(""); - } - - return [ - state, - reset, - { - [name]: state.value, - } - ]; -}; -\ No newline at end of file diff --git a/web/source/settings/components/form/file.jsx b/web/source/settings/components/form/file.jsx @@ -1,78 +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 React = require("react"); -const prettierBytes = require("prettier-bytes"); - -module.exports = function useFileInput({name, _Name}, { - withPreview, - maxSize, - initialInfo = "no file selected" -}) { - const [file, setFile] = React.useState(); - const [imageURL, setImageURL] = React.useState(); - const [info, setInfo] = React.useState(); - - function onChange(e) { - let file = e.target.files[0]; - setFile(file); - - URL.revokeObjectURL(imageURL); - - if (file != undefined) { - if (withPreview) { - setImageURL(URL.createObjectURL(file)); - } - - let size = prettierBytes(file.size); - if (maxSize && file.size > maxSize) { - size = <span className="error-text">{size}</span>; - } - - setInfo(<> - {file.name} ({size}) - </>); - } else { - setInfo(); - } - } - - function reset() { - URL.revokeObjectURL(imageURL); - setImageURL(); - setFile(); - setInfo(); - } - - return [ - onChange, - reset, - { - [name]: file, - [`${name}URL`]: imageURL, - [`${name}Info`]: <span className="form-info"> - {info - ? info - : initialInfo - } - </span> - } - ]; -}; -\ No newline at end of file diff --git a/web/source/settings/components/form/index.js b/web/source/settings/components/form/index.js @@ -1,37 +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"; - -function capitalizeFirst(str) { - return str.slice(0,1).toUpperCase()+str.slice(1); -} - -function makeHook(func) { - return (name, ...args) => func({ - name, - Name: capitalizeFirst(name) - }, - ...args); -} - -module.exports = { - useTextInput: makeHook(require("./text")), - useFileInput: makeHook(require("./file")), - useComboBoxInput: makeHook(require("./combobox")) -}; -\ No newline at end of file diff --git a/web/source/settings/components/form/inputs.jsx b/web/source/settings/components/form/inputs.jsx @@ -0,0 +1,141 @@ +/* + 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"); + +function TextInput({ label, field, ...inputProps }) { + const { onChange, value, ref } = field; + + return ( + <div className="form-field text"> + <label> + {label} + <input + type="text" + {...{ onChange, value, ref }} + {...inputProps} + /> + </label> + </div> + ); +} + +function TextArea({ label, field, ...inputProps }) { + const { onChange, value, ref } = field; + + return ( + <div className="form-field textarea"> + <label> + {label} + <textarea + type="text" + {...{ onChange, value, ref }} + {...inputProps} + /> + </label> + </div> + ); +} + +function FileInput({ label, field, ...inputProps }) { + const { onChange, ref, infoComponent } = field; + + return ( + <div className="form-field file"> + <label> + <div className="label">{label}</div> + <div className="file-input button">Browse</div> + {infoComponent} + {/* <a onClick={removeFile("header")}>remove</a> */} + <input + type="file" + className="hidden" + {...{ onChange, ref }} + {...inputProps} + /> + </label> + </div> + ); +} + +function Checkbox({ label, field, ...inputProps }) { + const { onChange, value } = field; + + return ( + <div className="form-field checkbox"> + <label> + <input + type="checkbox" + checked={value} + onChange={onChange} + {...inputProps} + /> {label} + </label> + </div> + ); +} + +function Select({ label, field, options, ...inputProps }) { + const { onChange, value, ref } = field; + + return ( + <div className="form-field select"> + <label> + {label} + <select + {...{ onChange, value, ref }} + {...inputProps} + > + {options} + </select> + </label> + </div> + ); +} + +function RadioGroup({ field, label, ...inputProps }) { + return ( + <div className="form-field radio"> + {Object.entries(field.options).map(([value, radioLabel]) => ( + <label key={value}> + <input + type="radio" + name={field.name} + value={value} + checked={field.value == value} + onChange={field.onChange} + {...inputProps} + /> + {radioLabel} + </label> + ))} + {label} + </div> + ); +} + +module.exports = { + TextInput, + TextArea, + FileInput, + Checkbox, + Select, + RadioGroup +}; +\ No newline at end of file diff --git a/web/source/settings/components/form/mutation-button.jsx b/web/source/settings/components/form/mutation-button.jsx @@ -0,0 +1,49 @@ +/* + 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 { Error } = require("../error"); + +module.exports = function MutationButton({ label, result, disabled, showError = true, className = "", ...inputProps }) { + let iconClass = ""; + const targetsThisButton = result.action == inputProps.name; // can also both be undefined, which is correct + + if (targetsThisButton) { + if (result.isLoading) { + iconClass = "fa-spin fa-refresh"; + } else if (result.isSuccess) { + iconClass = "fa-check fadeout"; + } + } + + return (<div> + {(showError && targetsThisButton && result.error) && + <Error error={result.error} /> + } + <button type="submit" className={"with-icon " + className} disabled={result.isLoading || disabled} {...inputProps}> + <i className={`fa fa-fw ${iconClass}`} aria-hidden="true"></i> + {(targetsThisButton && result.isLoading) + ? "Processing..." + : label + } + </button> + </div> + ); +}; +\ No newline at end of file diff --git a/web/source/settings/components/form/text.jsx b/web/source/settings/components/form/text.jsx @@ -1,56 +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 React = require("react"); - -module.exports = function useTextInput({name, Name}, {validator, defaultValue=""} = {}) { - const [text, setText] = React.useState(defaultValue); - const [valid, setValid] = React.useState(true); - const textRef = React.useRef(null); - - function onChange(e) { - let input = e.target.value; - setText(input); - } - - function reset() { - setText(""); - } - - React.useEffect(() => { - if (validator) { - let res = validator(text); - setValid(res == ""); - textRef.current.setCustomValidity(res); - textRef.current.reportValidity(); - } - }, [text, textRef, validator]); - - return [ - onChange, - reset, - { - [name]: text, - [`${name}Ref`]: textRef, - [`set${Name}`]: setText, - [`${name}Valid`]: valid - } - ]; -}; -\ No newline at end of file diff --git a/web/source/settings/components/loading.jsx b/web/source/settings/components/loading.jsx @@ -22,6 +22,6 @@ const React = require("react"); module.exports = function Loading() { return ( - <i className="fa fa-spin fa-refresh" aria-label="Loading" title="Loading"/> + <i className="fa fa-spin fa-refresh loading-icon" aria-label="Loading" title="Loading" /> ); }; \ No newline at end of file diff --git a/web/source/settings/components/login.jsx b/web/source/settings/components/login.jsx @@ -1,102 +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 { setInstance } = require("../redux/reducers/oauth").actions; -const api = require("../lib/api"); - -module.exports = function Login({error}) { - const dispatch = Redux.useDispatch(); - const [ instanceField, setInstanceField ] = React.useState(""); - const [ errorMsg, setErrorMsg ] = React.useState(); - const instanceFieldRef = React.useRef(""); - - React.useEffect(() => { - // check if current domain runs an instance - let currentDomain = window.location.origin; - Promise.try(() => { - return dispatch(api.instance.fetchWithoutStore(currentDomain)); - }).then(() => { - if (instanceFieldRef.current.length == 0) { // user hasn't started typing yet - dispatch(setInstance(currentDomain)); - instanceFieldRef.current = currentDomain; - setInstanceField(currentDomain); - } - }).catch((e) => { - console.log("Current domain does not host a valid instance: ", e); - }); - }, []); - - function tryInstance() { - let domain = instanceFieldRef.current; - Promise.try(() => { - return dispatch(api.instance.fetchWithoutStore(domain)).catch((e) => { - // TODO: clearer error messages for common errors - console.log(e); - throw e; - }); - }).then(() => { - dispatch(setInstance(domain)); - - return dispatch(api.oauth.register()).catch((e) => { - console.log(e); - throw e; - }); - }).then(() => { - return dispatch(api.oauth.authorize()); // will send user off-page - }).catch((e) => { - setErrorMsg( - <> - <b>{e.type}</b> - <span>{e.message}</span> - </> - ); - }); - } - - function updateInstanceField(e) { - if (e.key == "Enter") { - tryInstance(instanceField); - } else { - setInstanceField(e.target.value); - instanceFieldRef.current = e.target.value; - } - } - - return ( - <section className="login"> - <h1>OAUTH Login:</h1> - {error} - <form onSubmit={(e) => e.preventDefault()}> - <label htmlFor="instance">Instance: </label> - <input value={instanceField} onChange={updateInstanceField} id="instance"/> - {errorMsg && - <div className="error"> - {errorMsg} - </div> - } - <button onClick={tryInstance}>Authenticate</button> - </form> - </section> - ); -}; -\ No newline at end of file diff --git a/web/source/settings/components/mutation-button.jsx b/web/source/settings/components/mutation-button.jsx @@ -1,42 +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 React = require("react"); - -module.exports = function MutateButton({text, result}) { - let buttonText = text; - - if (result.isLoading) { - buttonText = "Processing..."; - } - - return (<div> - {result.error && - <section className="error">{result.error.status}: {result.error.data.error}</section> - } - <input - className="button" - type="submit" - disabled={result.isLoading} - value={buttonText} - /> - </div> - ); -}; -\ No newline at end of file diff --git a/web/source/settings/components/nav-button.jsx b/web/source/settings/components/nav-button.jsx @@ -21,7 +21,7 @@ const React = require("react"); const { Link, useRoute } = require("wouter"); -module.exports = function NavButton({href, name}) { +module.exports = function NavButton({ href, name }) { const [isActive] = useRoute(`${href}/:anything?`); return ( <Link href={href}> diff --git a/web/source/settings/components/submit.jsx b/web/source/settings/components/submit.jsx @@ -1,35 +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 React = require("react"); - -module.exports = function Submit({onClick, label, errorMsg, statusMsg}) { - return ( - <div className="messagebutton"> - <button type="submit" onClick={onClick}>{ label }</button> - {errorMsg.length > 0 && - <div className="error accent">{errorMsg}</div> - } - {statusMsg.length > 0 && - <div className="accent">{statusMsg}</div> - } - </div> - ); -}; diff --git a/web/source/settings/index.js b/web/source/settings/index.js @@ -18,20 +18,16 @@ "use strict"; -const Promise = require("bluebird"); const React = require("react"); const ReactDom = require("react-dom/client"); -const Redux = require("react-redux"); -const { Switch, Route, Redirect } = require("wouter"); const { Provider } = require("react-redux"); const { PersistGate } = require("redux-persist/integration/react"); +const { Switch, Route, Redirect } = require("wouter"); -const { store, persistor } = require("./redux"); -const api = require("./lib/api"); -const oauth = require("./redux/reducers/oauth").actions; -const { AuthenticationError } = require("./lib/errors"); +const query = require("./lib/query"); -const Login = require("./components/login"); +const { store, persistor } = require("./redux"); +const AuthorizationGate = require("./components/authorization"); const Loading = require("./components/loading"); require("./style.css"); @@ -46,7 +42,7 @@ const nav = { adminOnly: true, "Instance Settings": require("./admin/settings.js"), "Actions": require("./admin/actions"), - "Federation": require("./admin/federation.js"), + "Federation": require("./admin/federation"), }, "Custom Emoji": { adminOnly: true, @@ -57,123 +53,37 @@ const nav = { const { sidebar, panelRouter } = require("./lib/get-views")(nav); -function App() { - const dispatch = Redux.useDispatch(); - - const { loginState, isAdmin } = Redux.useSelector((state) => state.oauth); - const reduxTempStatus = Redux.useSelector((state) => state.temporary.status); +function App({ account }) { + const isAdmin = account.role == "admin"; + const [logoutQuery] = query.useLogoutMutation(); - const [errorMsg, setErrorMsg] = React.useState(); - const [tokenChecked, setTokenChecked] = React.useState(false); - - React.useEffect(() => { - if (loginState == "login" || loginState == "callback") { - Promise.try(() => { - // Process OAUTH authorization token from URL if available - if (loginState == "callback") { - let urlParams = new URLSearchParams(window.location.search); - let code = urlParams.get("code"); - - if (code == undefined) { - setErrorMsg(new Error("Waiting for OAUTH callback but no ?code= provided. You can try logging in again:")); - } else { - return dispatch(api.oauth.tokenize(code)); - } - } - }).then(() => { - // Fetch current instance info - return dispatch(api.instance.fetch()); - }).then(() => { - // Check currently stored auth token for validity if available - return dispatch(api.user.fetchAccount()); - }).then(() => { - setTokenChecked(true); - - return dispatch(api.oauth.checkIfAdmin()); - }).catch((e) => { - if (e instanceof AuthenticationError) { - dispatch(oauth.remove()); - e.message = "Stored OAUTH token no longer valid, please log in again."; - } - setErrorMsg(e); - console.error(e); - }); - } - }, [loginState, dispatch]); - - let ErrorElement = null; - if (errorMsg != undefined) { - ErrorElement = ( - <div className="error"> - <b>{errorMsg.type}</b> - <span>{errorMsg.message}</span> + return ( + <> + <div className="sidebar"> + {sidebar.all} + {isAdmin && sidebar.admin} + <button className="logout" onClick={logoutQuery}> + Log out + </button> </div> - ); - } - - const LogoutElement = ( - <button className="logout" onClick={() => { dispatch(api.oauth.logout()); }}> - Log out - </button> - ); - - if (reduxTempStatus != undefined) { - return ( - <section> - {reduxTempStatus} - </section> - ); - } else if (tokenChecked && loginState == "login") { - return ( - <> - <div className="sidebar"> - {sidebar.all} - {isAdmin && sidebar.admin} - {LogoutElement} - </div> - <section className="with-sidebar"> - {ErrorElement} - <Switch> - {panelRouter.all} - {isAdmin && panelRouter.admin} - <Route> {/* default route */} - <Redirect to="/settings/user" /> - </Route> - </Switch> - </section> - </> - ); - } else if (loginState == "none") { - return ( - <Login error={ErrorElement} /> - ); - } else { - let status; - - if (loginState == "login") { - status = "Verifying stored login..."; - } else if (loginState == "callback") { - status = "Processing OAUTH callback..."; - } - - return ( - <section> - <div> - {status} - </div> - {ErrorElement} - {LogoutElement} + <section className="with-sidebar"> + <Switch> + {panelRouter.all} + {isAdmin && panelRouter.admin} + <Route> + <Redirect to="/settings/user" /> + </Route> + </Switch> </section> - ); - } - + </> + ); } function Main() { return ( <Provider store={store}> - <PersistGate loading={<section><Loading/></section>} persistor={persistor}> - <App /> + <PersistGate loading={<section><Loading /></section>} persistor={persistor}> + <AuthorizationGate App={App} /> </PersistGate> </Provider> ); diff --git a/web/source/settings/lib/api/admin.js b/web/source/settings/lib/api/admin.js @@ -1,168 +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 isValidDomain = require("is-valid-domain"); - -const instance = require("../../redux/reducers/instances").actions; -const admin = require("../../redux/reducers/admin").actions; - -module.exports = function ({ apiCall, getChanges }) { - const adminAPI = { - updateInstance: function updateInstance() { - return function (dispatch, getState) { - return Promise.try(() => { - const state = getState().instances.adminSettings; - - const update = getChanges(state, { - formKeys: ["title", "short_description", "description", "contact_account.username", "email", "terms", "thumbnail_description"], - renamedKeys: { - "email": "contact_email", - "contact_account.username": "contact_username" - }, - fileKeys: ["thumbnail"] - }); - - return dispatch(apiCall("PATCH", "/api/v1/instance", update, "form")); - }).then((data) => { - return dispatch(instance.setInstanceInfo(data)); - }); - }; - }, - - fetchDomainBlocks: function fetchDomainBlocks() { - return function (dispatch, _getState) { - return Promise.try(() => { - return dispatch(apiCall("GET", "/api/v1/admin/domain_blocks")); - }).then((data) => { - return dispatch(admin.setBlockedInstances(data)); - }); - }; - }, - - updateDomainBlock: function updateDomainBlock(domain) { - return function (dispatch, getState) { - return Promise.try(() => { - const state = getState().admin.newInstanceBlocks[domain]; - const update = getChanges(state, { - formKeys: ["domain", "obfuscate", "public_comment", "private_comment"], - }); - - return dispatch(apiCall("POST", "/api/v1/admin/domain_blocks", update, "form")); - }).then((block) => { - return Promise.all([ - dispatch(admin.newDomainBlock([domain, block])), - dispatch(admin.setDomainBlock([domain, block])) - ]); - }); - }; - }, - - getEditableDomainBlock: function getEditableDomainBlock(domain) { - return function (dispatch, getState) { - let data = getState().admin.blockedInstances[domain]; - return dispatch(admin.newDomainBlock([domain, data])); - }; - }, - - bulkDomainBlock: function bulkDomainBlock() { - return function (dispatch, getState) { - let invalidDomains = []; - let success = 0; - - return Promise.try(() => { - const state = getState().admin.bulkBlock; - let list = state.list; - let domains; - - let fields = getChanges(state, { - formKeys: ["obfuscate", "public_comment", "private_comment"] - }); - - let defaultDate = new Date().toUTCString(); - - if (list[0] == "[") { - domains = JSON.parse(state.list); - } else { - domains = list.split("\n").map((line_) => { - let line = line_.trim(); - if (line.length == 0) { - return null; - } - - if (!isValidDomain(line, {wildcard: true, allowUnicode: true})) { - invalidDomains.push(line); - return null; - } - - return { - domain: line, - created_at: defaultDate, - ...fields - }; - }).filter((a) => a != null); - } - - if (domains.length == 0) { - return; - } - - const update = { - domains: new Blob([JSON.stringify(domains)], {type: "application/json"}) - }; - - return dispatch(apiCall("POST", "/api/v1/admin/domain_blocks?import=true", update, "form")); - }).then((blocks) => { - if (blocks != undefined) { - return Promise.each(blocks, (block) => { - success += 1; - return dispatch(admin.setDomainBlock([block.domain, block])); - }); - } - }).then(() => { - return { - success, - invalidDomains - }; - }); - }; - }, - - removeDomainBlock: function removeDomainBlock(domain) { - return function (dispatch, getState) { - return Promise.try(() => { - const id = getState().admin.blockedInstances[domain].id; - return dispatch(apiCall("DELETE", `/api/v1/admin/domain_blocks/${id}`)); - }).then((removed) => { - return dispatch(admin.removeDomainBlock(removed.domain)); - }); - }; - }, - - mediaCleanup: function mediaCleanup(days) { - return function (dispatch, _getState) { - return Promise.try(() => { - return dispatch(apiCall("POST", `/api/v1/admin/media_cleanup?remote_cache_days=${days}`)); - }); - }; - }, - }; - return adminAPI; -}; -\ No newline at end of file diff --git a/web/source/settings/lib/api/index.js b/web/source/settings/lib/api/index.js @@ -1,193 +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 { isPlainObject } = require("is-plain-object"); -const d = require("dotty"); - -const { APIError, AuthenticationError } = require("../errors"); -const { setInstanceInfo, setNamedInstanceInfo } = require("../../redux/reducers/instances").actions; - -function apiCall(method, route, payload, type = "json") { - return function (dispatch, getState) { - const state = getState(); - let base = state.oauth.instance; - let auth = state.oauth.token; - - return Promise.try(() => { - let url = new URL(base); - let [path, query] = route.split("?"); - url.pathname = path; - if (query != undefined) { - url.search = query; - } - let body; - - let headers = { - "Accept": "application/json", - }; - - if (payload != undefined) { - if (type == "json") { - headers["Content-Type"] = "application/json"; - body = JSON.stringify(payload); - } else if (type == "form") { - body = convertToForm(payload); - } - } - - if (auth != undefined) { - headers["Authorization"] = auth; - } - - return fetch(url.toString(), { - method, - headers, - body - }); - }).then((res) => { - // try parse json even with error - let json = res.json().catch((e) => { - throw new APIError(`JSON parsing error: ${e.message}`); - }); - - return Promise.all([res, json]); - }).then(([res, json]) => { - if (!res.ok) { - if (auth != undefined && (res.status == 401 || res.status == 403)) { - // stored access token is invalid - throw new AuthenticationError("401: Authentication error", {json, status: res.status}); - } else { - throw new APIError(json.error, { json }); - } - } else { - return json; - } - }); - }; -} - -/* - Takes an object with (nested) keys, and transforms it into - a FormData object to be sent over the API -*/ -function convertToForm(payload) { - const formData = new FormData(); - Object.entries(payload).forEach(([key, val]) => { - if (isPlainObject(val)) { - Object.entries(val).forEach(([key2, val2]) => { - if (val2 != undefined) { - formData.set(`${key}[${key2}]`, val2); - } - }); - } else { - if (val != undefined) { - formData.set(key, val); - } - } - }); - return formData; -} - -function getChanges(state, keys) { - const { formKeys = [], fileKeys = [], renamedKeys = {} } = keys; - const update = {}; - - formKeys.forEach((key) => { - let value = d.get(state, key); - if (value == undefined) { - return; - } - if (renamedKeys[key]) { - key = renamedKeys[key]; - } - d.put(update, key, value); - }); - - fileKeys.forEach((key) => { - let file = d.get(state, `${key}File`); - if (file != undefined) { - if (renamedKeys[key]) { - key = renamedKeys[key]; - } - d.put(update, key, file); - } - }); - - return update; -} - -function getCurrentUrl() { - let [pre, _past] = window.location.pathname.split("/settings"); - return `${window.location.origin}${pre}/settings`; -} - -function fetchInstanceWithoutStore(domain) { - return function (dispatch, getState) { - return Promise.try(() => { - let lookup = getState().instances.info[domain]; - if (lookup != undefined) { - return lookup; - } - - // apiCall expects to pull the domain from state, - // but we don't want to store it there yet - // so we mock the API here with our function argument - let fakeState = { - oauth: { instance: domain } - }; - - return apiCall("GET", "/api/v1/instance")(dispatch, () => fakeState); - }).then((json) => { - if (json && json.uri) { // TODO: validate instance json more? - dispatch(setNamedInstanceInfo([domain, json])); - return json; - } - }); - }; -} - -function fetchInstance() { - return function (dispatch, _getState) { - return Promise.try(() => { - return dispatch(apiCall("GET", "/api/v1/instance")); - }).then((json) => { - if (json && json.uri) { - dispatch(setInstanceInfo(json)); - return json; - } - }); - }; -} - -let submoduleArgs = { apiCall, getCurrentUrl, getChanges }; - -module.exports = { - instance: { - fetchWithoutStore: fetchInstanceWithoutStore, - fetch: fetchInstance - }, - oauth: require("./oauth")(submoduleArgs), - user: require("./user")(submoduleArgs), - admin: require("./admin")(submoduleArgs), - apiCall, - convertToForm, - getChanges -}; -\ No newline at end of file diff --git a/web/source/settings/lib/api/oauth.js b/web/source/settings/lib/api/oauth.js @@ -1,127 +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 { OAUTHError, AuthenticationError } = require("../errors"); - -const oauth = require("../../redux/reducers/oauth").actions; -const temporary = require("../../redux/reducers/temporary").actions; -const admin = require("../../redux/reducers/admin").actions; - -module.exports = function oauthAPI({ apiCall, getCurrentUrl }) { - return { - - register: function register(scopes = []) { - return function (dispatch, _getState) { - return Promise.try(() => { - return dispatch(apiCall("POST", "/api/v1/apps", { - client_name: "GoToSocial Settings", - scopes: scopes.join(" "), - redirect_uris: getCurrentUrl(), - website: getCurrentUrl() - })); - }).then((json) => { - json.scopes = scopes; - dispatch(oauth.setRegistration(json)); - }); - }; - }, - - authorize: function authorize() { - return function (dispatch, getState) { - let state = getState(); - let reg = state.oauth.registration; - let base = new URL(state.oauth.instance); - - base.pathname = "/oauth/authorize"; - base.searchParams.set("client_id", reg.client_id); - base.searchParams.set("redirect_uri", getCurrentUrl()); - base.searchParams.set("response_type", "code"); - base.searchParams.set("scope", reg.scopes.join(" ")); - - dispatch(oauth.setLoginState("callback")); - dispatch(temporary.setStatus("Redirecting to instance login...")); - - // send user to instance's login flow - window.location.assign(base.href); - }; - }, - - tokenize: function tokenize(code) { - return function (dispatch, getState) { - let reg = getState().oauth.registration; - - return Promise.try(() => { - if (reg == undefined || reg.client_id == undefined) { - throw new OAUTHError("Callback code present, but no client registration is available from localStorage. \nNote: localStorage is unavailable in Private Browsing."); - } - - return dispatch(apiCall("POST", "/oauth/token", { - client_id: reg.client_id, - client_secret: reg.client_secret, - redirect_uri: getCurrentUrl(), - grant_type: "authorization_code", - code: code - })); - }).then((json) => { - window.history.replaceState({}, document.title, window.location.pathname); - return dispatch(oauth.login(json)); - }); - }; - }, - - checkIfAdmin: function checkIfAdmin() { - return function (dispatch, getState) { - const state = getState(); - let stored = state.oauth.isAdmin; - if (stored != undefined) { - return stored; - } - - // newer GoToSocial version will include a `role` in the Account data, check that first - if (state.user.profile.role == "admin") { - dispatch(oauth.setAdmin(true)); - return true; - } - - // no role info, try fetching an admin-only route and see if we get an error - return Promise.try(() => { - return dispatch(apiCall("GET", "/api/v1/admin/domain_blocks")); - }).then((data) => { - return Promise.all([ - dispatch(oauth.setAdmin(true)), - dispatch(admin.setBlockedInstances(data)) - ]); - }).catch(AuthenticationError, () => { - return dispatch(oauth.setAdmin(false)); - }); - }; - }, - - logout: function logout() { - return function (dispatch, _getState) { - // TODO: GoToSocial does not have a logout API route yet - - return dispatch(oauth.remove()); - }; - } - }; -}; -\ No newline at end of file diff --git a/web/source/settings/lib/api/user.js b/web/source/settings/lib/api/user.js @@ -1,67 +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 user = require("../../redux/reducers/user").actions; - -module.exports = function ({ apiCall, getChanges }) { - function updateCredentials(selector, keys) { - return function (dispatch, getState) { - return Promise.try(() => { - const state = selector(getState()); - - const update = getChanges(state, keys); - - return dispatch(apiCall("PATCH", "/api/v1/accounts/update_credentials", update, "form")); - }).then((account) => { - return dispatch(user.setAccount(account)); - }); - }; - } - - return { - fetchAccount: function fetchAccount() { - return function (dispatch, _getState) { - return Promise.try(() => { - return dispatch(apiCall("GET", "/api/v1/accounts/verify_credentials")); - }).then((account) => { - return dispatch(user.setAccount(account)); - }); - }; - }, - - updateProfile: function updateProfile() { - const formKeys = ["display_name", "locked", "source", "custom_css", "source.note", "enable_rss"]; - const renamedKeys = { - "source.note": "note" - }; - const fileKeys = ["header", "avatar"]; - - return updateCredentials((state) => state.user.profile, {formKeys, renamedKeys, fileKeys}); - }, - - updateSettings: function updateProfile() { - const formKeys = ["source"]; - - return updateCredentials((state) => state.user.settings, {formKeys}); - } - }; -}; -\ No newline at end of file diff --git a/web/source/settings/lib/errors.js b/web/source/settings/lib/errors.js @@ -1,27 +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 createError = require("create-error"); - -module.exports = { - APIError: createError("APIError"), - OAUTHError: createError("OAUTHError"), - AuthenticationError: createError("AuthenticationError"), -}; -\ No newline at end of file diff --git a/web/source/settings/lib/form/bool.jsx b/web/source/settings/lib/form/bool.jsx @@ -0,0 +1,50 @@ +/* + 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"); + +module.exports = function useBoolInput({ name, Name }, { defaultValue = false } = {}) { + const [value, setValue] = React.useState(defaultValue); + + function onChange(e) { + setValue(e.target.checked); + } + + function reset() { + setValue(defaultValue); + } + + // Array / Object hybrid, for easier access in different contexts + return Object.assign([ + onChange, + reset, + { + [name]: value, + [`set${Name}`]: setValue + } + ], { + name, + onChange, + reset, + value, + setter: setValue, + hasChanged: () => value != defaultValue + }); +}; +\ No newline at end of file diff --git a/web/source/settings/lib/form/check-list.jsx b/web/source/settings/lib/form/check-list.jsx @@ -0,0 +1,147 @@ +/* + 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 syncpipe = require("syncpipe"); + +function createState(entries, uniqueKey, oldState, defaultValue) { + return syncpipe(entries, [ + (_) => _.map((entry) => { + let key = entry[uniqueKey]; + return [ + key, + { + ...entry, + key, + checked: oldState[key]?.checked ?? entry.checked ?? defaultValue + } + ]; + }), + (_) => Object.fromEntries(_) + ]); +} + +function updateAllState(state, newValue) { + return syncpipe(state, [ + (_) => Object.values(_), + (_) => _.map((entry) => [entry.key, { + ...entry, + checked: newValue + }]), + (_) => Object.fromEntries(_) + ]); +} + +function updateState(state, key, newValue) { + return { + ...state, + [key]: { + ...state[key], + ...newValue + } + }; +} + +module.exports = function useCheckListInput({ name }, { entries, uniqueKey = "key", defaultValue = false }) { + const [state, setState] = React.useState({}); + + const [someSelected, setSomeSelected] = React.useState(false); + const [toggleAllState, setToggleAllState] = React.useState(0); + const toggleAllRef = React.useRef(null); + + React.useEffect(() => { + /* + entries changed, update state, + re-using old state if available for key + */ + setState(createState(entries, uniqueKey, state, defaultValue)); + + /* eslint-disable-next-line react-hooks/exhaustive-deps */ + }, [entries]); + + 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(state); + /* one or more boxes are checked */ + let some = values.some((v) => v.checked); + + let all = false; + if (some) { + /* there's not at least one unchecked box */ + all = !values.some((v) => v.checked == false); + } + + setSomeSelected(some); + + if (some && !all) { + setToggleAllState(2); + toggleAllRef.current.indeterminate = true; + } else { + setToggleAllState(all ? 1 : 0); + toggleAllRef.current.indeterminate = false; + } + }, [state, toggleAllRef]); + + function toggleAll(e) { + let selectAll = e.target.checked; + + if (toggleAllState == 2) { // indeterminate + selectAll = false; + } + + setState(updateAllState(state, selectAll)); + setToggleAllState(selectAll); + } + + function reset() { + setState(updateAllState(state, defaultValue)); + } + + function selectedValues() { + return syncpipe(state, [ + (_) => Object.values(_), + (_) => _.filter((entry) => entry.checked) + ]); + } + + return Object.assign([ + state, + reset, + { name } + ], { + name, + value: state, + onChange: (key, newValue) => setState(updateState(state, key, newValue)), + selectedValues, + reset, + someSelected, + toggleAll: { + ref: toggleAllRef, + value: toggleAllState, + onChange: toggleAll + } + }); +}; +\ No newline at end of file diff --git a/web/source/settings/lib/form/combo-box.jsx b/web/source/settings/lib/form/combo-box.jsx @@ -0,0 +1,56 @@ +/* + 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 { useComboboxState } = require("ariakit/combobox"); + +module.exports = function useComboBoxInput({ name, Name }, { defaultValue } = {}) { + const [isNew, setIsNew] = React.useState(false); + + const state = useComboboxState({ + defaultValue, + gutter: 0, + sameWidth: true + }); + + function reset() { + state.setValue(""); + } + + return Object.assign([ + state, + reset, + { + [name]: state.value, + name, + [`${name}IsNew`]: isNew, + [`set${Name}IsNew`]: setIsNew + } + ], { + name, + state, + value: state.value, + hasChanged: () => state.value != defaultValue, + isNew, + setIsNew, + reset + }); +}; +\ No newline at end of file diff --git a/web/source/settings/lib/form/file.jsx b/web/source/settings/lib/form/file.jsx @@ -0,0 +1,91 @@ +/* + 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 prettierBytes = require("prettier-bytes"); + +module.exports = function useFileInput({ name, _Name }, { + withPreview, + maxSize, + initialInfo = "no file selected" +} = {}) { + const [file, setFile] = React.useState(); + const [imageURL, setImageURL] = React.useState(); + const [info, setInfo] = React.useState(); + + function onChange(e) { + let file = e.target.files[0]; + setFile(file); + + URL.revokeObjectURL(imageURL); + + if (file != undefined) { + if (withPreview) { + setImageURL(URL.createObjectURL(file)); + } + + let size = prettierBytes(file.size); + if (maxSize && file.size > maxSize) { + size = <span className="error-text">{size}</span>; + } + + setInfo(<> + {file.name} ({size}) + </>); + } else { + setInfo(); + } + } + + function reset() { + URL.revokeObjectURL(imageURL); + setImageURL(); + setFile(); + setInfo(); + } + + const infoComponent = ( + <span className="form-info"> + {info + ? info + : initialInfo + } + </span> + ); + + // Array / Object hybrid, for easier access in different contexts + return Object.assign([ + onChange, + reset, + { + [name]: file, + [`${name}URL`]: imageURL, + [`${name}Info`]: infoComponent, + } + ], { + onChange, + reset, + name, + value: file, + previewValue: imageURL, + hasChanged: () => file != undefined, + infoComponent + }); +}; +\ No newline at end of file diff --git a/web/source/settings/lib/form/form-with-data.jsx b/web/source/settings/lib/form/form-with-data.jsx @@ -0,0 +1,39 @@ +/* + 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 Loading = require("../../components/loading"); + +// Wrap Form component inside component that fires the RTK Query call, +// so Form will only be rendered when data is available to generate form-fields for +module.exports = function FormWithData({ dataQuery, DataForm, queryArg, ...formProps }) { + const { data, isLoading } = dataQuery(queryArg); + + if (isLoading) { + return ( + <div> + <Loading /> + </div> + ); + } else { + return <DataForm data={data} {...formProps} />; + } +}; +\ No newline at end of file diff --git a/web/source/settings/lib/form/index.js b/web/source/settings/lib/form/index.js @@ -0,0 +1,46 @@ +/* + 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"; + +function capitalizeFirst(str) { + return str.slice(0, 1).toUpperCase() + str.slice(1); +} + +function makeHook(func) { + return (name, ...args) => func({ + name, + Name: capitalizeFirst(name) + }, ...args); +} + +module.exports = { + useTextInput: makeHook(require("./text")), + useFileInput: makeHook(require("./file")), + useBoolInput: makeHook(require("./bool")), + useRadioInput: makeHook(require("./radio")), + useComboBoxInput: makeHook(require("./combo-box")), + useCheckListInput: makeHook(require("./check-list")), + useValue: function (name, value) { + return { + name, + value, + hasChanged: () => true // always included + }; + } +}; +\ No newline at end of file diff --git a/web/source/settings/lib/form/radio.jsx b/web/source/settings/lib/form/radio.jsx @@ -0,0 +1,51 @@ +/* + 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"); + +module.exports = function useRadioInput({ name, Name }, { defaultValue, options } = {}) { + const [value, setValue] = React.useState(defaultValue); + + function onChange(e) { + setValue(e.target.value); + } + + function reset() { + setValue(defaultValue); + } + + // Array / Object hybrid, for easier access in different contexts + return Object.assign([ + onChange, + reset, + { + [name]: value, + [`set${Name}`]: setValue + } + ], { + name, + onChange, + reset, + value, + setter: setValue, + options, + hasChanged: () => value != defaultValue + }); +}; +\ No newline at end of file diff --git a/web/source/settings/lib/form/submit.js b/web/source/settings/lib/form/submit.js @@ -0,0 +1,83 @@ +/* + 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 syncpipe = require("syncpipe"); + +module.exports = function useFormSubmit(form, mutationQuery, { changedOnly = true } = {}) { + if (!Array.isArray(mutationQuery)) { + throw new ("useFormSubmit: mutationQuery was not an Array. Is a valid useMutation RTK Query provided?"); + } + const [runMutation, result] = mutationQuery; + const [usedAction, setUsedAction] = React.useState(); + return [ + function submitForm(e) { + let action; + if (e?.preventDefault) { + e.preventDefault(); + action = e.nativeEvent.submitter.name; + } else { + action = e; + } + + if (action == "") { + action = undefined; + } + setUsedAction(action); + // transform the field definitions into an object with just their values + let updatedFields = []; + const mutationData = syncpipe(form, [ + (_) => Object.values(_), + (_) => _.map((field) => { + if (field.selectedValues != undefined) { + let selected = field.selectedValues(); + if (!changedOnly || selected.length > 0) { + updatedFields.push(field); + return [field.name, selected]; + } + } else if (!changedOnly || field.hasChanged()) { + updatedFields.push(field); + return [field.name, field.value]; + } + return null; + }), + (_) => _.filter((value) => value != null), + (_) => Object.fromEntries(_) + ]); + + mutationData.action = action; + + return Promise.try(() => { + return runMutation(mutationData); + }).then((res) => { + if (res.error == undefined) { + updatedFields.forEach((field) => { + field.reset(); + }); + } + }); + }, + { + ...result, + action: usedAction + } + ]; +}; +\ No newline at end of file diff --git a/web/source/settings/lib/form/text.jsx b/web/source/settings/lib/form/text.jsx @@ -0,0 +1,67 @@ +/* + 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"); + +module.exports = function useTextInput({ name, Name }, { validator, defaultValue = "", dontReset = false } = {}) { + const [text, setText] = React.useState(defaultValue); + const [valid, setValid] = React.useState(true); + const textRef = React.useRef(null); + + function onChange(e) { + let input = e.target.value; + setText(input); + } + + function reset() { + if (!dontReset) { + setText(defaultValue); + } + } + + React.useEffect(() => { + if (validator && textRef.current) { + let res = validator(text); + setValid(res == ""); + textRef.current.setCustomValidity(res); + } + }, [text, textRef, validator]); + + // Array / Object hybrid, for easier access in different contexts + return Object.assign([ + onChange, + reset, + { + [name]: text, + [`${name}Ref`]: textRef, + [`set${Name}`]: setText, + [`${name}Valid`]: valid, + } + ], { + onChange, + reset, + name, + value: text, + ref: textRef, + setter: setText, + valid, + hasChanged: () => text != defaultValue + }); +}; +\ No newline at end of file diff --git a/web/source/settings/lib/get-views.js b/web/source/settings/lib/get-views.js @@ -22,7 +22,7 @@ const React = require("react"); const { Link, Route, Redirect } = require("wouter"); const { ErrorBoundary } = require("react-error-boundary"); -const ErrorFallback = require("../components/error"); +const { ErrorFallback } = require("../components/error"); const NavButton = require("../components/nav-button"); function urlSafe(str) { @@ -64,7 +64,7 @@ module.exports = function getViews(struct) { } panelRouterEl.push(( - <Route path={`${url}/:page?`} key={url}> + <Route path={`${url}/:page*`} key={url}> <ErrorBoundary FallbackComponent={ErrorFallback} onReset={() => { }}> {/* FIXME: implement onReset */} <ViewComponent /> diff --git a/web/source/settings/lib/query/admin/custom-emoji.js b/web/source/settings/lib/query/admin/custom-emoji.js @@ -0,0 +1,195 @@ +/* + 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 { unwrapRes } = require("../lib"); + +module.exports = (build) => ({ + getAllEmoji: build.query({ + query: (params = {}) => ({ + url: "/api/v1/admin/custom_emojis", + params: { + limit: 0, + ...params + } + }), + providesTags: (res) => + res + ? [...res.map((emoji) => ({ type: "Emojis", id: emoji.id })), { type: "Emojis", id: "LIST" }] + : [{ type: "Emojis", id: "LIST" }] + }), + + getEmoji: build.query({ + query: (id) => ({ + url: `/api/v1/admin/custom_emojis/${id}` + }), + providesTags: (res, error, id) => [{ type: "Emojis", id }] + }), + + addEmoji: build.mutation({ + query: (form) => { + return { + method: "POST", + url: `/api/v1/admin/custom_emojis`, + asForm: true, + body: form, + discardEmpty: true + }; + }, + invalidatesTags: (res) => + res + ? [{ type: "Emojis", id: "LIST" }, { type: "Emojis", id: res.id }] + : [{ type: "Emojis", id: "LIST" }] + }), + + editEmoji: build.mutation({ + query: ({ id, ...patch }) => { + return { + method: "PATCH", + url: `/api/v1/admin/custom_emojis/${id}`, + asForm: true, + body: { + type: "modify", + ...patch + } + }; + }, + invalidatesTags: (res) => + res + ? [{ type: "Emojis", id: "LIST" }, { type: "Emojis", id: res.id }] + : [{ type: "Emojis", id: "LIST" }] + }), + + deleteEmoji: build.mutation({ + query: (id) => ({ + method: "DELETE", + url: `/api/v1/admin/custom_emojis/${id}` + }), + invalidatesTags: (res, error, id) => [{ type: "Emojis", id }] + }), + + searchStatusForEmoji: build.mutation({ + queryFn: (url, api, _extraOpts, baseQuery) => { + return Promise.try(() => { + return baseQuery({ + url: `/api/v2/search?q=${encodeURIComponent(url)}&resolve=true&limit=1` + }).then(unwrapRes); + }).then((searchRes) => { + return emojiFromSearchResult(searchRes); + }).then(({ type, domain, list }) => { + const state = api.getState(); + if (domain == new URL(state.oauth.instance).host) { + throw "LOCAL_INSTANCE"; + } + + // search for every mentioned emoji with the admin api to get their ID + return Promise.map(list, (emoji) => { + return baseQuery({ + url: `/api/v1/admin/custom_emojis`, + params: { + filter: `domain:${domain},shortcode:${emoji.shortcode}`, + limit: 1 + } + }).then((unwrapRes)).then((list) => list[0]); + }, { concurrency: 5 }).then((listWithIDs) => { + return { + data: { + type, + domain, + list: listWithIDs + } + }; + }); + }).catch((e) => { + return { error: e }; + }); + } + }), + + patchRemoteEmojis: build.mutation({ + queryFn: ({ action, ...formData }, _api, _extraOpts, baseQuery) => { + const data = []; + const errors = []; + + return Promise.each(formData.selectedEmoji, (emoji) => { + return Promise.try(() => { + let body = { + type: action + }; + + if (action == "copy") { + body.shortcode = emoji.shortcode; + if (formData.category.trim().length != 0) { + body.category = formData.category; + } + } + + return baseQuery({ + method: "PATCH", + url: `/api/v1/admin/custom_emojis/${emoji.id}`, + asForm: true, + body: body + }).then(unwrapRes); + }).then((res) => { + data.push([emoji.shortcode, res]); + }).catch((e) => { + let msg = e.message ?? e; + if (e.data.error) { + msg = e.data.error; + } + errors.push([emoji.shortcode, msg]); + }); + }).then(() => { + if (errors.length == 0) { + return { data }; + } else { + return { + error: errors + }; + } + }); + }, + invalidatesTags: () => [{ type: "Emojis", id: "LIST" }] + }) +}); + +function emojiFromSearchResult(searchRes) { + /* Parses the search response, prioritizing a toot result, + and returns referenced custom emoji + */ + let type; + + if (searchRes.statuses.length > 0) { + type = "statuses"; + } else if (searchRes.accounts.length > 0) { + type = "accounts"; + } else { + throw "NONE_FOUND"; + } + + let data = searchRes[type][0]; + + return { + type, + domain: (new URL(data.url)).host, // to get WEB_DOMAIN, see https://github.com/superseriousbusiness/gotosocial/issues/1225 + list: data.emojis + }; +} +\ No newline at end of file diff --git a/web/source/settings/lib/query/admin/import-export.js b/web/source/settings/lib/query/admin/import-export.js @@ -0,0 +1,212 @@ +/* + 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 isValidDomain = require("is-valid-domain"); +const fileDownload = require("js-file-download"); + +const { + replaceCacheOnMutation, + domainListToObject, + unwrapRes +} = require("../lib"); + +function parseDomainList(list) { + if (list[0] == "[") { + return JSON.parse(list); + } else { + return list.split("\n").map((line) => { + let domain = line.trim(); + let valid = true; + if (domain.startsWith("http")) { + try { + domain = new URL(domain).hostname; + } catch (e) { + valid = false; + } + } + return domain.length > 0 + ? { domain, valid } + : null; + }).filter((a) => a); // not `null` + } +} + +function validateDomainList(list) { + list.forEach((entry) => { + entry.valid = (entry.valid !== false) && isValidDomain(entry.domain, { wildcard: true, allowUnicode: true }); + entry.checked = entry.valid; + }); + + return list; +} + +function deduplicateDomainList(list) { + let domains = new Set(); + return list.filter((entry) => { + if (domains.has(entry.domain)) { + return false; + } else { + domains.add(entry.domain); + return true; + } + }); +} + +module.exports = (build) => ({ + processDomainList: build.mutation({ + queryFn: (formData) => { + return Promise.try(() => { + if (formData.domains == undefined || formData.domains.length == 0) { + throw "No domains entered"; + } + return parseDomainList(formData.domains); + }).then((parsed) => { + return deduplicateDomainList(parsed); + }).then((deduped) => { + return validateDomainList(deduped); + }).then((data) => { + return { data }; + }).catch((e) => { + return { error: e.toString() }; + }); + } + }), + exportDomainList: build.mutation({ + queryFn: (formData, api, _extraOpts, baseQuery) => { + return Promise.try(() => { + return baseQuery({ + url: `/api/v1/admin/domain_blocks` + }); + }).then(unwrapRes).then((blockedInstances) => { + return blockedInstances.map((entry) => { + if (formData.exportType == "json") { + return { + domain: entry.domain, + public_comment: entry.public_comment + }; + } else { + return entry.domain; + } + }); + }).then((exportList) => { + if (formData.exportType == "json") { + return JSON.stringify(exportList); + } else { + return exportList.join("\n"); + } + }).then((exportAsString) => { + if (formData.action == "export") { + return { + data: exportAsString + }; + } else if (formData.action == "export-file") { + let domain = new URL(api.getState().oauth.instance).host; + let date = new Date(); + let mime; + + let filename = [ + domain, + "blocklist", + date.getFullYear(), + (date.getMonth() + 1).toString().padStart(2, "0"), + date.getDate().toString().padStart(2, "0"), + ].join("-"); + + if (formData.exportType == "json") { + filename += ".json"; + mime = "application/json"; + } else { + filename += ".txt"; + mime = "text/plain"; + } + + fileDownload(exportAsString, filename, mime); + } + return { data: null }; + }).catch((e) => { + return { error: e }; + }); + } + }), + importDomainList: build.mutation({ + query: (formData) => { + const { domains } = formData; + + // add/replace comments, obfuscation data + let process = entryProcessor(formData); + domains.forEach((entry) => { + process(entry); + }); + + return { + method: "POST", + url: `/api/v1/admin/domain_blocks?import=true`, + asForm: true, + discardEmpty: true, + body: { + domains: new Blob([JSON.stringify(domains)], { type: "application/json" }) + } + }; + }, + transformResponse: domainListToObject, + ...replaceCacheOnMutation("instanceBlocks") + }) +}); + +function entryProcessor(formData) { + let funcs = []; + + ["private_comment", "public_comment"].forEach((type) => { + let text = formData[type].trim(); + + if (text.length > 0) { + let behavior = formData[`${type}_behavior`]; + + if (behavior == "append") { + funcs.push(function appendComment(entry) { + if (entry[type] == undefined) { + entry[type] = text; + } else { + entry[type] = [entry[type], text].join("\n"); + } + }); + } else if (behavior == "replace") { + funcs.push(function replaceComment(entry) { + entry[type] = text; + }); + } + } + }); + + return function process(entry) { + funcs.forEach((func) => { + func(entry); + }); + + entry.obfuscate = formData.obfuscate; + + Object.entries(entry).forEach(([key, val]) => { + if (val == undefined) { + delete entry[key]; + } + }); + }; +} +\ No newline at end of file diff --git a/web/source/settings/lib/query/admin/index.js b/web/source/settings/lib/query/admin/index.js @@ -0,0 +1,84 @@ +/* + 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 { + replaceCacheOnMutation, + removeFromCacheOnMutation, + domainListToObject +} = require("../lib"); +const base = require("../base"); + +const endpoints = (build) => ({ + updateInstance: build.mutation({ + query: (formData) => ({ + method: "PATCH", + url: `/api/v1/instance`, + asForm: true, + body: formData, + discardEmpty: true + }), + ...replaceCacheOnMutation("instance") + }), + mediaCleanup: build.mutation({ + query: (days) => ({ + method: "POST", + url: `/api/v1/admin/media_cleanup`, + params: { + remote_cache_days: days + } + }) + }), + instanceBlocks: build.query({ + query: () => ({ + url: `/api/v1/admin/domain_blocks` + }), + transformResponse: domainListToObject + }), + addInstanceBlock: build.mutation({ + query: (formData) => ({ + method: "POST", + url: `/api/v1/admin/domain_blocks`, + asForm: true, + body: formData, + discardEmpty: true + }), + transformResponse: (data) => { + return { + [data.domain]: data + }; + }, + ...replaceCacheOnMutation("instanceBlocks") + }), + removeInstanceBlock: build.mutation({ + query: (id) => ({ + method: "DELETE", + url: `/api/v1/admin/domain_blocks/${id}`, + }), + ...removeFromCacheOnMutation("instanceBlocks", { + findKey: (_draft, newData) => { + return newData.domain; + } + }) + }), + ...require("./import-export")(build), + ...require("./custom-emoji")(build) +}); + +module.exports = base.injectEndpoints({ endpoints }); +\ No newline at end of file diff --git a/web/source/settings/lib/query/base.js b/web/source/settings/lib/query/base.js @@ -1,35 +1,57 @@ /* - 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 { createApi, fetchBaseQuery } = require("@reduxjs/toolkit/query/react"); +const { isPlainObject } = require("is-plain-object"); -const { convertToForm } = require("../api"); +function convertToForm(obj) { + const formData = new FormData(); + Object.entries(obj).forEach(([key, val]) => { + if (isPlainObject(val)) { + Object.entries(val).forEach(([key2, val2]) => { + if (val2 != undefined) { + formData.set(`${key}[${key2}]`, val2); + } + }); + } else if (val != undefined) { + formData.set(key, val); + } + }); + return formData; +} function instanceBasedQuery(args, api, extraOptions) { const state = api.getState(); - const {instance, token} = state.oauth; + const { instance, token } = state.oauth; if (args.baseUrl == undefined) { args.baseUrl = instance; } + if (args.discardEmpty) { + if (args.body == undefined || Object.keys(args.body).length == 0) { + return { data: null }; + } + delete args.discardEmpty; + } + if (args.asForm) { delete args.asForm; args.body = convertToForm(args.body); @@ -50,6 +72,12 @@ function instanceBasedQuery(args, api, extraOptions) { module.exports = createApi({ reducerPath: "api", baseQuery: instanceBasedQuery, - tagTypes: ["Emojis"], - endpoints: () => ({}) + tagTypes: ["Auth"], + endpoints: (build) => ({ + instance: build.query({ + query: () => ({ + url: `/api/v1/instance` + }) + }) + }) }); \ No newline at end of file diff --git a/web/source/settings/lib/query/custom-emoji.js b/web/source/settings/lib/query/custom-emoji.js @@ -1,180 +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 base = require("./base"); - -function unwrap(res) { - if (res.error != undefined) { - throw res.error; - } else { - return res.data; - } -} - -const endpoints = (build) => ({ - getAllEmoji: build.query({ - query: (params = {}) => ({ - url: "/api/v1/admin/custom_emojis", - params: { - limit: 0, - ...params - } - }), - providesTags: (res) => - res - ? [...res.map((emoji) => ({type: "Emojis", id: emoji.id})), {type: "Emojis", id: "LIST"}] - : [{type: "Emojis", id: "LIST"}] - }), - getEmoji: build.query({ - query: (id) => ({ - url: `/api/v1/admin/custom_emojis/${id}` - }), - providesTags: (res, error, id) => [{type: "Emojis", id}] - }), - addEmoji: build.mutation({ - query: (form) => { - return { - method: "POST", - url: `/api/v1/admin/custom_emojis`, - asForm: true, - body: form - }; - }, - invalidatesTags: (res) => - res - ? [{type: "Emojis", id: "LIST"}, {type: "Emojis", id: res.id}] - : [{type: "Emojis", id: "LIST"}] - }), - editEmoji: build.mutation({ - query: ({id, ...patch}) => { - return { - method: "PATCH", - url: `/api/v1/admin/custom_emojis/${id}`, - asForm: true, - body: { - type: "modify", - ...patch - } - }; - }, - invalidatesTags: (res) => - res - ? [{type: "Emojis", id: "LIST"}, {type: "Emojis", id: res.id}] - : [{type: "Emojis", id: "LIST"}] - }), - deleteEmoji: build.mutation({ - query: (id) => ({ - method: "DELETE", - url: `/api/v1/admin/custom_emojis/${id}` - }), - invalidatesTags: (res, error, id) => [{type: "Emojis", id}] - }), - searchStatusForEmoji: build.mutation({ - query: (url) => ({ - method: "GET", - url: `/api/v2/search?q=${encodeURIComponent(url)}&resolve=true&limit=1` - }), - transformResponse: (res) => { - /* Parses search response, prioritizing a toot result, - and returns referenced custom emoji - */ - let type; - - if (res.statuses.length > 0) { - type = "statuses"; - } else if (res.accounts.length > 0) { - type = "accounts"; - } else { - return { - type: "none" - }; - } - - let data = res[type][0]; - - return { - type, - domain: (new URL(data.url)).host, // to get WEB_DOMAIN, see https://github.com/superseriousbusiness/gotosocial/issues/1225 - list: data.emojis - }; - } - }), - patchRemoteEmojis: build.mutation({ - queryFn: ({action, domain, list, category}, api, _extraOpts, baseQuery) => { - const data = []; - const errors = []; - - return Promise.each(list, (emoji) => { - return Promise.try(() => { - return baseQuery({ - method: "GET", - url: `/api/v1/admin/custom_emojis`, - params: { - filter: `domain:${domain},shortcode:${emoji.shortcode}`, - limit: 1 - } - }).then(unwrap); - }).then(([lookup]) => { - if (lookup == undefined) { throw "not found"; } - - let body = { - type: action - }; - - if (action == "copy") { - body.shortcode = emoji.localShortcode ?? emoji.shortcode; - if (category.trim().length != 0) { - body.category = category; - } - } - - return baseQuery({ - method: "PATCH", - url: `/api/v1/admin/custom_emojis/${lookup.id}`, - asForm: true, - body: body - }).then(unwrap); - }).then((res) => { - data.push([emoji.shortcode, res]); - }).catch((e) => { - console.error("emoji lookup for", emoji.shortcode, "failed:", e); - let msg = e.message ?? e; - if (e.data.error) { - msg = e.data.error; - } - errors.push([emoji.shortcode, msg]); - }); - }).then(() => { - if (errors.length == 0) { - return { data }; - } else { - return { - error: errors - }; - } - }); - }, - invalidatesTags: () => [{type: "Emojis", id: "LIST"}] - }) -}); - -module.exports = base.injectEndpoints({endpoints}); -\ No newline at end of file diff --git a/web/source/settings/lib/query/index.js b/web/source/settings/lib/query/index.js @@ -1,24 +1,26 @@ /* - 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"; module.exports = { ...require("./base"), - ...require("./custom-emoji.js") + ...require("./oauth"), + ...require("./user"), + ...require("./admin") }; \ No newline at end of file diff --git a/web/source/settings/lib/query/lib.js b/web/source/settings/lib/query/lib.js @@ -0,0 +1,75 @@ +/* + 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 syncpipe = require("syncpipe"); +const base = require("./base"); + +module.exports = { + unwrapRes(res) { + if (res.error != undefined) { + throw res.error; + } else { + return res.data; + } + }, + domainListToObject: (data) => { + // Turn flat Array into Object keyed by block's domain + return syncpipe(data, [ + (_) => _.map((entry) => [entry.domain, entry]), + (_) => Object.fromEntries(_) + ]); + }, + replaceCacheOnMutation: makeCacheMutation((draft, newData) => { + Object.assign(draft, newData); + }), + appendCacheOnMutation: makeCacheMutation((draft, newData) => { + draft.push(newData); + }), + spliceCacheOnMutation: makeCacheMutation((draft, newData, { key }) => { + draft.splice(key, 1); + }), + updateCacheOnMutation: makeCacheMutation((draft, newData, { key }) => { + draft[key] = newData; + }), + removeFromCacheOnMutation: makeCacheMutation((draft, newData, { key }) => { + delete draft[key]; + }), + editCacheOnMutation: makeCacheMutation((draft, newData, { update }) => { + update(draft, newData); + }) +}; + +// https://redux-toolkit.js.org/rtk-query/usage/manual-cache-updates#pessimistic-updates +function makeCacheMutation(action) { + return function cacheMutation(queryName, { key, findKey, arg, ...opts } = {}) { + return { + onQueryStarted: (_, { dispatch, queryFulfilled }) => { + queryFulfilled.then(({ data: newData }) => { + dispatch(base.util.updateQueryData(queryName, arg, (draft) => { + if (findKey != undefined) { + key = findKey(draft, newData); + } + action(draft, newData, { key, ...opts }); + })); + }); + } + }; + }; +} +\ No newline at end of file diff --git a/web/source/settings/lib/query/oauth.js b/web/source/settings/lib/query/oauth.js @@ -0,0 +1,158 @@ +/* + 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 base = require("./base"); +const { unwrapRes } = require("./lib"); +const oauth = require("../../redux/oauth").actions; + +function getSettingsURL() { + /* needed in case the settings interface isn't hosted at /settings but + some subpath like /gotosocial/settings. Other parts of the code don't + take this into account yet so mostly future-proofing. + + Also drops anything past /settings/, because authorization urls that are too long + get rejected by GTS. + */ + let [pre, _past] = window.location.pathname.split("/settings"); + return `${window.location.origin}${pre}/settings`; +} + +const SETTINGS_URL = getSettingsURL(); + +const endpoints = (build) => ({ + verifyCredentials: build.query({ + providesTags: (_res, error) => + error == undefined + ? ["Auth"] + : [], + queryFn: (_arg, api, _extraOpts, baseQuery) => { + const state = api.getState(); + + return Promise.try(() => { + // Process callback code first, if available + if (state.oauth.loginState == "callback") { + let urlParams = new URLSearchParams(window.location.search); + let code = urlParams.get("code"); + + if (code == undefined) { + throw { + message: "Waiting for callback, but no ?code= provided in url." + }; + } else { + let app = state.oauth.registration; + + if (app == undefined || app.client_id == undefined) { + throw { + message: "No stored registration data, can't finish login flow." + }; + } + + return baseQuery({ + method: "POST", + url: "/oauth/token", + body: { + client_id: app.client_id, + client_secret: app.client_secret, + redirect_uri: SETTINGS_URL, + grant_type: "authorization_code", + code: code + } + }).then(unwrapRes).then((token) => { + // remove ?code= from url + window.history.replaceState({}, document.title, window.location.pathname); + api.dispatch(oauth.setToken(token)); + }); + } + } + }).then(() => { + return baseQuery({ + url: `/api/v1/accounts/verify_credentials` + }); + }).catch((e) => { + return { error: e }; + }); + } + }), + authorizeFlow: build.mutation({ + queryFn: (formData, api, _extraOpts, baseQuery) => { + let instance; + const state = api.getState(); + + return Promise.try(() => { + if (!formData.instance.startsWith("http")) { + formData.instance = `https://${formData.instance}`; + } + instance = new URL(formData.instance).origin; + + const stored = state.oauth.instance; + if (stored?.instance == instance && stored.registration) { + return stored.registration; + } + + return baseQuery({ + method: "POST", + baseUrl: instance, + url: "/api/v1/apps", + body: { + client_name: "GoToSocial Settings", + scopes: formData.scopes, + redirect_uris: SETTINGS_URL, + website: SETTINGS_URL + } + }).then(unwrapRes).then((app) => { + app.scopes = formData.scopes; + + api.dispatch(oauth.setInstance({ + instance: instance, + registration: app, + loginState: "callback" + })); + + return app; + }); + }).then((app) => { + let url = new URL(instance); + url.pathname = "/oauth/authorize"; + url.searchParams.set("client_id", app.client_id); + url.searchParams.set("redirect_uri", SETTINGS_URL); + url.searchParams.set("response_type", "code"); + url.searchParams.set("scope", app.scopes); + + let redirectURL = url.toString(); + window.location.assign(redirectURL); + + return { data: null }; + }).catch((e) => { + return { error: e }; + }); + }, + }), + logout: build.mutation({ + queryFn: (_arg, api) => { + api.dispatch(oauth.remove()); + return { data: null }; + }, + invalidatesTags: ["Auth"] + }) +}); + +module.exports = base.injectEndpoints({ endpoints }); +\ No newline at end of file diff --git a/web/source/settings/lib/query/user.js b/web/source/settings/lib/query/user.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 { replaceCacheOnMutation } = require("./lib"); +const base = require("./base"); + +const endpoints = (build) => ({ + updateCredentials: build.mutation({ + query: (formData) => ({ + method: "PATCH", + url: `/api/v1/accounts/update_credentials`, + asForm: true, + body: formData, + discardEmpty: true + }), + ...replaceCacheOnMutation("verifyCredentials") + }), + passwordChange: build.mutation({ + query: (data) => ({ + method: "POST", + url: `/api/v1/user/password_change`, + body: data + }) + }) +}); + +module.exports = base.injectEndpoints({ endpoints }); +\ No newline at end of file diff --git a/web/source/settings/lib/submit.js b/web/source/settings/lib/submit.js @@ -1,48 +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"); - -module.exports = function submit(func, { - setStatus, setError, - startStatus="PATCHing", successStatus="Saved!", - onSuccess, - onError -}) { - return function() { - setStatus(startStatus); - setError(""); - return Promise.try(() => { - return func(); - }).then(() => { - setStatus(successStatus); - if (onSuccess != undefined) { - return onSuccess(); - } - }).catch((e) => { - setError(e.message); - setStatus(""); - console.error(e); - if (onError != undefined) { - onError(e); - } - }); - }; -}; -\ No newline at end of file diff --git a/web/source/settings/redux/index.js b/web/source/settings/redux/index.js @@ -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"; @@ -34,18 +34,14 @@ const { const query = require("../lib/query/base"); const combinedReducers = combineReducers({ - oauth: require("./reducers/oauth").reducer, - instances: require("./reducers/instances").reducer, - temporary: require("./reducers/temporary").reducer, - user: require("./reducers/user").reducer, - admin: require("./reducers/admin").reducer, + oauth: require("./oauth").reducer, [query.reducerPath]: query.reducer }); const persistedReducer = persistReducer({ key: "gotosocial-settings", storage: require("redux-persist/lib/storage").default, - stateReconciler: require("redux-persist/lib/stateReconciler/autoMergeLevel2").default, + stateReconciler: require("redux-persist/lib/stateReconciler/autoMergeLevel1").default, whitelist: ["oauth"], }, combinedReducers); @@ -54,7 +50,7 @@ const store = configureStore({ middleware: (getDefaultMiddleware) => { return getDefaultMiddleware({ serializableCheck: { - ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER, "temporary/setScrollElement"] + ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER] } }).concat(query.middleware); } diff --git a/web/source/settings/redux/oauth.js b/web/source/settings/redux/oauth.js @@ -0,0 +1,48 @@ +/* + 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 { createSlice } = require("@reduxjs/toolkit"); + +module.exports = createSlice({ + name: "oauth", + initialState: { + loginState: 'none' + }, + reducers: { + setInstance: (state, { payload }) => { + return { + ...state, + ...payload /* overrides instance, registration keys */ + }; + }, + authorize: (state) => { + state.loginState = "callback"; + }, + setToken: (state, { payload }) => { + state.token = `${payload.token_type} ${payload.access_token}`; + state.loginState = "login"; + }, + remove: (state, { _payload }) => { + delete state.token; + delete state.registration; + state.loginState = "logout"; + } + } +}); +\ No newline at end of file diff --git a/web/source/settings/redux/reducers/admin.js b/web/source/settings/redux/reducers/admin.js @@ -1,99 +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 { createSlice } = require("@reduxjs/toolkit"); - -function sortBlocks(blocks) { - return blocks.sort((a, b) => { // alphabetical sort - return a.domain.localeCompare(b.domain); - }); -} - -function emptyBlock() { - return { - public_comment: "", - private_comment: "", - obfuscate: false - }; -} - -module.exports = createSlice({ - name: "admin", - initialState: { - loadedBlockedInstances: false, - blockedInstances: undefined, - bulkBlock: { - list: "", - exportType: "plain", - ...emptyBlock() - }, - newInstanceBlocks: {} - }, - reducers: { - setBlockedInstances: (state, { payload }) => { - state.blockedInstances = {}; - sortBlocks(payload).forEach((entry) => { - state.blockedInstances[entry.domain] = entry; - }); - state.loadedBlockedInstances = true; - }, - - newDomainBlock: (state, { payload: [domain, data] }) => { - if (data == undefined) { - data = { - new: true, - domain, - ...emptyBlock() - }; - } - state.newInstanceBlocks[domain] = data; - }, - - setDomainBlock: (state, { payload: [domain, data = {}] }) => { - state.blockedInstances[domain] = data; - }, - - removeDomainBlock: (state, {payload: domain}) => { - delete state.blockedInstances[domain]; - }, - - updateDomainBlockVal: (state, { payload: [domain, key, val] }) => { - state.newInstanceBlocks[domain][key] = val; - }, - - updateBulkBlockVal: (state, { payload: [key, val] }) => { - state.bulkBlock[key] = val; - }, - - resetBulkBlockVal: (state, { _payload }) => { - state.bulkBlock = { - list: "", - exportType: "plain", - ...emptyBlock() - }; - }, - - exportToField: (state, { _payload }) => { - state.bulkBlock.list = Object.values(state.blockedInstances).map((entry) => { - return entry.domain; - }).join("\n"); - } - } -}); -\ No newline at end of file diff --git a/web/source/settings/redux/reducers/instances.js b/web/source/settings/redux/reducers/instances.js @@ -1,42 +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 {createSlice} = require("@reduxjs/toolkit"); -const d = require("dotty"); - -module.exports = createSlice({ - name: "instances", - initialState: { - info: {}, - }, - reducers: { - setNamedInstanceInfo: (state, {payload}) => { - let [key, info] = payload; - state.info[key] = info; - }, - setInstanceInfo: (state, {payload}) => { - state.current = payload; - state.adminSettings = payload; - }, - setAdminSettingsVal: (state, {payload: [key, val]}) => { - d.put(state.adminSettings, key, val); - } - } -}); -\ No newline at end of file diff --git a/web/source/settings/redux/reducers/oauth.js b/web/source/settings/redux/reducers/oauth.js @@ -1,52 +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 {createSlice} = require("@reduxjs/toolkit"); - -module.exports = createSlice({ - name: "oauth", - initialState: { - loginState: 'none', - }, - reducers: { - setInstance: (state, {payload}) => { - state.instance = payload; - }, - setRegistration: (state, {payload}) => { - state.registration = payload; - }, - setLoginState: (state, {payload}) => { - state.loginState = payload; - }, - login: (state, {payload}) => { - state.token = `${payload.token_type} ${payload.access_token}`; - state.loginState = "login"; - }, - remove: (state, {_payload}) => { - delete state.token; - delete state.registration; - delete state.isAdmin; - state.loginState = "none"; - }, - setAdmin: (state, {payload}) => { - state.isAdmin = payload; - } - } -}); -\ No newline at end of file diff --git a/web/source/settings/redux/reducers/temporary.js b/web/source/settings/redux/reducers/temporary.js @@ -1,32 +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 {createSlice} = require("@reduxjs/toolkit"); - -module.exports = createSlice({ - name: "temporary", - initialState: { - }, - reducers: { - setStatus: function(state, {payload}) { - state.status = payload; - } - } -}); -\ No newline at end of file diff --git a/web/source/settings/redux/reducers/user.js b/web/source/settings/redux/reducers/user.js @@ -1,50 +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 { createSlice } = require("@reduxjs/toolkit"); -const d = require("dotty"); - -module.exports = createSlice({ - name: "user", - initialState: { - profile: {}, - settings: {} - }, - reducers: { - setAccount: (state, { payload }) => { - payload.source = payload.source ?? {}; - payload.source.language = payload.source.language.toUpperCase() ?? "EN"; - payload.source.status_format = payload.source.status_format ?? "plain"; - payload.source.sensitive = payload.source.sensitive ?? false; - - state.profile = payload; - // /user/settings only needs a copy of the 'source' obj - state.settings = { - source: payload.source - }; - }, - setProfileVal: (state, { payload: [key, val] }) => { - d.put(state.profile, key, val); - }, - setSettingsVal: (state, { payload: [key, val] }) => { - d.put(state.settings, key, val); - } - } -}); -\ No newline at end of file diff --git a/web/source/settings/style.css b/web/source/settings/style.css @@ -16,6 +16,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. */ +/* Fork-Awesome 'fa-fw' fixed icon width + keep in sync with https://github.com/ForkAwesome/Fork-Awesome/blob/a99579ae3e735ee70e51ed62dfcee3172b5b2db7/css/fork-awesome.css#L50 +*/ +$fa-fw: 1.28571429em; + body { grid-template-rows: auto 1fr; } @@ -40,7 +45,7 @@ section { border-top-left-radius: 0; border-bottom-left-radius: 0; - & > div { + & > div, & > form { border-left: 0.2rem solid $border-accent; padding-left: 0.4rem; display: flex; @@ -50,7 +55,7 @@ section { h2 { margin: 0; - margin-bottom: 0.5rem; + margin-top: 0.1rem; } &:only-child { @@ -213,7 +218,7 @@ input, select, textarea { ) !important; } -section.with-sidebar > div { +section.with-sidebar > div, section.with-sidebar > form { display: flex; flex-direction: column; gap: 1rem; @@ -223,19 +228,17 @@ section.with-sidebar > div { line-height: 1.5rem; } + button { + width: auto; + align-self: flex-start; + line-height: 1.5rem; + } + input[type=checkbox] { justify-self: start; width: initial; } - input:read-only { - border: none; - } - - input:invalid { - border-color: red; - } - textarea { width: 100%; } @@ -337,15 +340,24 @@ section.with-sidebar > div { } } +form { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + .form-field label { font-weight: bold; } -.form-field.file { - width: 100%; - display: flex; -} +.form-field.file label { + display: grid; + grid-template-columns: auto 1fr; + .label { + grid-column: 1 / span 2; + } +} span.form-info { flex: 1 1 auto; @@ -353,39 +365,70 @@ span.form-info { text-overflow: ellipsis; white-space: nowrap; padding: 0.3rem 0; + font-weight: initial; } .list { display: flex; flex-direction: column; - margin-top: 0.5rem; - max-height: 40rem; - overflow: auto; + + &.scrolling { + max-height: 40rem; + overflow: auto; + } + + .header, .entry { + padding: 0.5rem; + } + + .header { + border: 0.1rem solid transparent; /* for alignment with .entry border padding */ + background: $gray2; + display: flex; + } + + input[type=checkbox] { + margin-left: 0.5rem; + } .entry { display: flex; flex-wrap: wrap; background: $settings-entry-bg; + border: 0.1rem solid transparent; + &:nth-child(even) { + background: $settings-entry-alternate-bg; + } + &:hover { background: $settings-entry-hover-bg; } + + &:active, &:focus, &:hover { + border-color: $fg-accent; + } + } +} + +.checkbox-list { + .header, .entry { + gap: 1rem; } } .instance-list { + p { + margin-top: 0; + } + .filter { display: flex; gap: 0.5rem; - - input { - width: auto; - flex: 1 1 auto; - } } .entry { - padding: 0.3rem; + padding: 0.5rem; margin: 0.2rem 0; #domain { @@ -406,9 +449,12 @@ span.form-info { background: $settings-entry-bg; .entry { - padding: 0.5rem; flex-direction: column; + b { + padding-left: 0.4rem; + } + .emoji-group { display: flex; flex-wrap: wrap; @@ -550,6 +596,7 @@ span.form-info { .row { display: flex; + gap: 0.5rem; } .emoji-detail { @@ -566,6 +613,12 @@ span.form-info { align-items: center; gap: 0.5rem; + div { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + img { height: 8.5rem; width: 8.5rem; @@ -576,15 +629,13 @@ span.form-info { } .update-category { - margin-bottom: 1rem; .combobox-wrapper button { font-size: 1rem; margin: 0.15rem 0; } .row { - margin-top: 0.4rem; - gap: 0.5rem; + margin-top: 0.1rem; } } @@ -607,36 +658,18 @@ span.form-info { flex-direction: column; gap: 1rem; - & > span { - margin-bottom: -1rem; + span { + margin-bottom: -0.5rem; } .action-buttons { gap: 1rem; } - .emoji-list { - display: flex; - flex-direction: column; - - & > * { - gap: 1rem; - align-items: center; - padding: 0.5rem 1rem; - } - - .header { - background: $gray2; - display: flex; - } - - .row { + .checkbox-list { + .entry { display: grid; grid-template-columns: auto auto 1fr; - - &:hover { - background: $settings-entry-hover-bg; - } } .emoji { @@ -646,4 +679,94 @@ span.form-info { } } } +} + +.info { + color: $info-fg; + background: $info-bg; + padding: 0.5rem; + border-radius: $br; + + display: flex; + gap: 0.5rem; + align-items: center; + + i { + margin-top: 0.1em; + } + + a { + color: $info-link; + } +} + +button.with-icon { + display: flex; + align-content: center; + padding-right: calc(0.5rem + $fa-fw); + + .fa { + align-self: center; + } +} + +button.with-padding { + padding: 0.5rem calc(0.5rem + $fa-fw); +} + +.loading-icon { + align-self: flex-start; +} + +.fadeout { + animation-name: fadeout; + animation-duration: 0.5s; + animation-delay: 2s; + animation-fill-mode: forwards; +} + +.suspend-import-list { + .checkbox-list { + .header, .entry { + display: grid; + grid-template-columns: auto 25ch auto 1fr; + } + } + + .entry { + #icon { + margin-left: -0.5rem; + align-self: center; + } + + #icon .already-blocked { + color: $green1; + } + + p { + align-self: center; + margin: 0; + } + } +} + +.form-field.radio { + &, label { + display: flex; + gap: 0.5rem; + } + + input { + width: auto; + place-self: center; + } +} + +@keyframes fadeout { + from { + opacity: 1; + } + to { + opacity: 0; + } } \ No newline at end of file diff --git a/web/source/settings/user/profile.js b/web/source/settings/user/profile.js @@ -19,88 +19,126 @@ "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 user = require("../redux/reducers/user").actions; -const submit = require("../lib/submit"); +const { + useTextInput, + useFileInput, + useBoolInput +} = require("../lib/form"); -const FakeProfile = require("../components/fake-profile"); -const { formFields } = require("../components/form-fields"); +const useFormSubmit = require("../lib/form/submit"); const { TextInput, TextArea, - Checkbox, - File -} = formFields(user.setProfileVal, (state) => state.user.profile); + FileInput, + Checkbox +} = require("../components/form/inputs"); + +const FormWithData = require("../lib/form/form-with-data"); +const FakeProfile = require("../components/fake-profile"); +const MutationButton = require("../components/form/mutation-button"); module.exports = function UserProfile() { - const dispatch = Redux.useDispatch(); - const instance = Redux.useSelector(state => state.instances.current); + return ( + <FormWithData + dataQuery={query.useVerifyCredentialsQuery} + DataForm={UserProfileForm} + /> + ); +}; - const allowCustomCSS = instance.configuration.accounts.allow_custom_css; +function UserProfileForm({ data: profile }) { + /* + User profile update form keys + - bool bot + - bool locked + - string display_name + - string note + - file avatar + - file header + - bool enable_rss + - string custom_css (if enabled) + */ - const [errorMsg, setError] = React.useState(""); - const [statusMsg, setStatus] = React.useState(""); + const { data: instance } = query.useInstanceQuery(); + const allowCustomCSS = React.useMemo(() => { + return instance?.configuration?.accounts?.allow_custom_css === true; + }, [instance]); - const saveProfile = submit( - () => dispatch(api.user.updateProfile()), - {setStatus, setError} - ); + const form = { + avatar: useFileInput("avatar", { withPreview: true }), + header: useFileInput("header", { withPreview: true }), + displayName: useTextInput("display_name", { defaultValue: profile.display_name }), + note: useTextInput("note", { defaultValue: profile.source?.note }), + customCSS: useTextInput("custom_css", { defaultValue: profile.custom_css }), + bot: useBoolInput("bot", { defaultValue: profile.bot }), + locked: useBoolInput("locked", { defaultValue: profile.locked }), + enableRSS: useBoolInput("enable_rss", { defaultValue: profile.enable_rss }), + }; + + const [submitForm, result] = useFormSubmit(form, query.useUpdateCredentialsMutation()); return ( - <div className="user-profile"> + <form className="user-profile" onSubmit={submitForm}> <h1>Profile</h1> <div className="overview"> - <FakeProfile/> + <FakeProfile + avatar={form.avatar.previewValue ?? profile.avatar} + header={form.header.previewValue ?? profile.header} + display_name={form.displayName.value ?? profile.username} + username={profile.username} + role={profile.role} + /> <div className="files"> <div> <h3>Header</h3> - <File - id="header" - fileType="image/*" + <FileInput + field={form.header} + accept="image/*" /> </div> <div> <h3>Avatar</h3> - <File - id="avatar" - fileType="image/*" + <FileInput + field={form.avatar} + accept="image/*" /> </div> </div> </div> <TextInput - id="display_name" - name="Name" - placeHolder="A GoToSocial user" + field={form.displayName} + label="Name" + placeholder="A GoToSocial user" /> <TextArea - id="source.note" - name="Bio" - placeHolder="Just trying out GoToSocial, my pronouns are they/them and I like sloths." + field={form.note} + label="Bio" + placeholder="Just trying out GoToSocial, my pronouns are they/them and I like sloths." + rows={8} /> <Checkbox - id="locked" - name="Manually approve follow requests" + field={form.locked} + label="Manually approve follow requests" /> <Checkbox - id="enable_rss" - name="Enable RSS feed of Public posts" + field={form.enableRSS} + label="Enable RSS feed of Public posts" /> - { !allowCustomCSS ? null : + {!allowCustomCSS ? null : <TextArea - id="custom_css" - name="Custom CSS" + field={form.customCSS} + label="Custom CSS" className="monospace" + rows={8} > <a href="https://docs.gotosocial.org/en/latest/user_guide/custom_css" target="_blank" className="moreinfolink" rel="noreferrer">Learn more about custom profile CSS (opens in a new tab)</a> </TextArea> } - <Submit onClick={saveProfile} label="Save profile info" errorMsg={errorMsg} statusMsg={statusMsg} /> - </div> + <MutationButton label="Save profile info" result={result} /> + </form> ); -}; -\ No newline at end of file +} +\ No newline at end of file diff --git a/web/source/settings/user/settings.js b/web/source/settings/user/settings.js @@ -18,42 +18,63 @@ "use strict"; -const Promise = require("bluebird"); const React = require("react"); -const Redux = require("react-redux"); -const api = require("../lib/api"); -const user = require("../redux/reducers/user").actions; -const submit = require("../lib/submit"); +const query = require("../lib/query"); -const Languages = require("../components/languages"); -const Submit = require("../components/submit"); +const { + useTextInput, + useBoolInput +} = require("../lib/form"); + +const useFormSubmit = require("../lib/form/submit"); const { - Checkbox, Select, -} = require("../components/form-fields").formFields(user.setSettingsVal, (state) => state.user.settings); + TextInput, + Checkbox +} = require("../components/form/inputs"); + +const FormWithData = require("../lib/form/form-with-data"); +const Languages = require("../components/languages"); +const MutationButton = require("../components/form/mutation-button"); module.exports = function UserSettings() { - const dispatch = Redux.useDispatch(); + return ( + <FormWithData + dataQuery={query.useVerifyCredentialsQuery} + DataForm={UserSettingsForm} + /> + ); +}; - const [errorMsg, setError] = React.useState(""); - const [statusMsg, setStatus] = React.useState(""); +function UserSettingsForm({ data }) { + const { source } = data; + /* form keys + - string source[privacy] + - bool source[sensitive] + - string source[language] + - string source[status_format] + */ - const updateSettings = submit( - () => dispatch(api.user.updateSettings()), - {setStatus, setError} - ); + const form = { + defaultPrivacy: useTextInput("source[privacy]", { defaultValue: source.privacy ?? "unlisted" }), + isSensitive: useBoolInput("source[sensitive]", { defaultValue: source.sensitive }), + language: useTextInput("source[language]", { defaultValue: source.language?.toUpperCase() ?? "EN" }), + format: useTextInput("source[status_format]", { defaultValue: source.status_format ?? "plain" }), + }; + + const [submitForm, result] = useFormSubmit(form, query.useUpdateCredentialsMutation()); return ( <> - <div className="user-settings"> + <form className="user-settings" onSubmit={submitForm}> <h1>Post settings</h1> - <Select id="source.language" name="Default post language" options={ - <Languages/> + <Select field={form.language} label="Default post language" options={ + <Languages /> }> </Select> - <Select id="source.privacy" name="Default post privacy" options={ + <Select field={form.defaultPrivacy} label="Default post privacy" options={ <> <option value="private">Private / followers-only</option> <option value="unlisted">Unlisted</option> @@ -62,7 +83,7 @@ module.exports = function UserSettings() { }> <a href="https://docs.gotosocial.org/en/latest/user_guide/posts/#privacy-settings" target="_blank" className="moreinfolink" rel="noreferrer">Learn more about post privacy settings (opens in a new tab)</a> </Select> - <Select id="source.status_format" name="Default post (and bio) format" options={ + <Select field={form.format} label="Default post (and bio) format" options={ <> <option value="plain">Plain (default)</option> <option value="markdown">Markdown</option> @@ -71,70 +92,50 @@ module.exports = function UserSettings() { <a href="https://docs.gotosocial.org/en/latest/user_guide/posts/#input-types" target="_blank" className="moreinfolink" rel="noreferrer">Learn more about post format settings (opens in a new tab)</a> </Select> <Checkbox - id="source.sensitive" - name="Mark my posts as sensitive by default" + field={form.isSensitive} + label="Mark my posts as sensitive by default" /> - <Submit onClick={updateSettings} label="Save post settings" errorMsg={errorMsg} statusMsg={statusMsg}/> - </div> + <MutationButton label="Save settings" result={result} /> + </form> <div> - <PasswordChange/> + <PasswordChange /> </div> </> ); -}; +} function PasswordChange() { - const dispatch = Redux.useDispatch(); - - const [errorMsg, setError] = React.useState(""); - const [statusMsg, setStatus] = React.useState(""); - - const [oldPassword, setOldPassword] = React.useState(""); - const [newPassword, setNewPassword] = React.useState(""); - const [newPasswordConfirm, setNewPasswordConfirm] = React.useState(""); - - function changePassword() { - if (newPassword !== newPasswordConfirm) { - setError("New password and confirm new password did not match!"); - return; + const form = { + oldPassword: useTextInput("old_password"), + newPassword: useTextInput("old_password", { + validator(val) { + if (val != "" && val == form.oldPassword.value) { + return "New password same as old password"; + } + return ""; + } + }) + }; + + const verifyNewPassword = useTextInput("verifyNewPassword", { + validator(val) { + if (val != "" && val != form.newPassword.value) { + return "Passwords do not match"; + } + return ""; } + }); - setStatus("PATCHing"); - setError(""); - return Promise.try(() => { - let data = { - old_password: oldPassword, - new_password: newPassword - }; - return dispatch(api.apiCall("POST", "/api/v1/user/password_change", data, "form")); - }).then(() => { - setStatus("Saved!"); - setOldPassword(""); - setNewPassword(""); - setNewPasswordConfirm(""); - }).catch((e) => { - setError(e.message); - setStatus(""); - }); - } + const [submitForm, result] = useFormSubmit(form, query.usePasswordChangeMutation()); return ( - <> + <form className="change-password" onSubmit={submitForm}> <h1>Change password</h1> - <div className="labelinput"> - <label htmlFor="password">Current password</label> - <input name="password" id="password" type="password" autoComplete="current-password" value={oldPassword} onChange={(e) => setOldPassword(e.target.value)} /> - </div> - <div className="labelinput"> - <label htmlFor="new-password">New password</label> - <input name="new-password" id="new-password" type="password" autoComplete="new-password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} /> - </div> - <div className="labelinput"> - <label htmlFor="confirm-new-password">Confirm new password</label> - <input name="confirm-new-password" id="confirm-new-password" type="password" autoComplete="new-password" value={newPasswordConfirm} onChange={(e) => setNewPasswordConfirm(e.target.value)} /> - </div> - <Submit onClick={changePassword} label="Save new password" errorMsg={errorMsg} statusMsg={statusMsg}/> - </> + <TextInput type="password" field={form.oldPassword} label="Current password" /> + <TextInput type="password" field={form.newPassword} label="New password" /> + <TextInput type="password" field={verifyNewPassword} label="Confirm new password" /> + <MutationButton label="Change password" result={result} /> + </form> ); } \ No newline at end of file diff --git a/web/source/yarn.lock b/web/source/yarn.lock @@ -2463,11 +2463,6 @@ domain-browser@^1.2.0: resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda" integrity sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA== -dotty@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/dotty/-/dotty-0.1.2.tgz#512d44cc4111a724931226259297f235e8484f6f" - integrity sha512-V0EWmKeH3DEhMwAZ+8ZB2Ao4OK6p++Z0hsDtZq3N0+0ZMVqkzrcEGROvOnZpLnvBg5PTNG23JEDLAm64gPaotQ== - duplexer2@^0.1.2, duplexer2@~0.1.0, duplexer2@~0.1.2, duplexer2@~0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1" @@ -2653,6 +2648,13 @@ escodegen@^1.11.1: optionalDependencies: source-map "~0.6.1" +eslint-plugin-license-header@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-license-header/-/eslint-plugin-license-header-0.6.0.tgz#81b0bab59da5a752d3a129f04bd0ca35bb6b07a2" + integrity sha512-IEywStBWaDBDMkogYoKUAdaOuomZ+YaQmdoSD2vHmXobekM+XuP6SWLlvwUUhIbdocn3MTlb5CUJ8E4VHz1c/w== + dependencies: + requireindex "^1.2.0" + eslint-plugin-react-hooks@^4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz#4c3e697ad95b77e93f8646aaa1630c1ba607edd3" @@ -4631,6 +4633,11 @@ remove-accents@0.4.2: resolved "https://registry.yarnpkg.com/remove-accents/-/remove-accents-0.4.2.tgz#0a43d3aaae1e80db919e07ae254b285d9e1c7bb5" integrity sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA== +requireindex@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/requireindex/-/requireindex-1.2.0.tgz#3463cdb22ee151902635aa6c9535d4de9c2ef1ef" + integrity sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww== + requires-port@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff"