[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
This commit is contained in:
f0x52 2023-01-18 14:45:14 +01:00 committed by GitHub
parent 974ec80a20
commit 9b139b6320
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
69 changed files with 3129 additions and 2663 deletions

View file

@ -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"; "use strict";
module.exports = { module.exports = {
"extends": ["@joepie91/eslint-config/react"] "extends": ["@joepie91/eslint-config/react"],
"plugins": ["license-header"],
"rules": {
"license-header/header": ["error", ".license-header.js"]
}
}; };

View file

@ -15,18 +15,3 @@
You should have received a copy of the GNU Affero General Public License 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/>. 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;
}
}
});

View file

@ -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) */ $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) */ $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) */ $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; $fg: $white1;
$bg: $gray1; $bg: $gray1;
@ -92,6 +98,7 @@ $avatar-border: $orange2;
$input-bg: $gray4; $input-bg: $gray4;
$input-disabled-bg: $gray2; $input-disabled-bg: $gray2;
$input-border: $blue1; $input-border: $blue1;
$input-error-border: $error3;
$input-focus-border: $blue3; $input-focus-border: $blue3;
$settings-nav-bg: $bg-accent; $settings-nav-bg: $bg-accent;
@ -107,5 +114,6 @@ $settings-nav-bg-active: $gray2;
$error-fg: $error1; $error-fg: $error1;
$error-bg: $error2; $error-bg: $error2;
$settings-entry-bg: $gray3; $settings-entry-bg: $gray2;
$settings-entry-alternate-bg: $gray3;
$settings-entry-hover-bg: $gray4; $settings-entry-hover-bg: $gray4;

View file

@ -311,12 +311,16 @@ input, select, textarea, .input {
font-size: 1rem; font-size: 1rem;
padding: 0.3rem; padding: 0.3rem;
&:focus { &:focus, &:active {
border-color: $input-focus-border; border-color: $input-focus-border;
} }
&:invalid {
border-color: $input-error-border;
}
&:disabled { &:disabled {
background: $input-disabled-bg; background: transparent;
} }
} }

View file

@ -32,7 +32,7 @@ const prodCfg = {
global: true, global: true,
exts: ".js" exts: ".js"
}], }],
["@browserify/envify", {global: true}] ["@browserify/envify", { global: true }]
] ]
}; };
@ -66,6 +66,7 @@ skulk({
], ],
}, },
settings: { settings: {
debug: false,
entryFile: "settings", entryFile: "settings",
outputFile: "settings.js", outputFile: "settings.js",
prodCfg: prodCfg, prodCfg: prodCfg,

View file

@ -6,7 +6,7 @@
"author": "f0x", "author": "f0x",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"scripts": { "scripts": {
"lint": "eslint .", "lint": "eslint . --ext .js,.jsx",
"build": "node index.js", "build": "node index.js",
"dev": "NODE_ENV=development node index.js" "dev": "NODE_ENV=development node index.js"
}, },
@ -14,7 +14,6 @@
"@reduxjs/toolkit": "^1.8.6", "@reduxjs/toolkit": "^1.8.6",
"ariakit": "^2.0.0-next.41", "ariakit": "^2.0.0-next.41",
"bluebird": "^3.7.2", "bluebird": "^3.7.2",
"dotty": "^0.1.2",
"is-valid-domain": "^0.1.6", "is-valid-domain": "^0.1.6",
"js-file-download": "^0.4.12", "js-file-download": "^0.4.12",
"langs": "^2.0.0", "langs": "^2.0.0",
@ -44,6 +43,7 @@
"babelify": "^10.0.0", "babelify": "^10.0.0",
"css-extract": "^2.0.0", "css-extract": "^2.0.0",
"eslint": "^8.26.0", "eslint": "^8.26.0",
"eslint-plugin-license-header": "^0.6.0",
"eslint-plugin-react": "^7.31.10", "eslint-plugin-react": "^7.31.10",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"factor-bundle": "^2.5.0", "factor-bundle": "^2.5.0",

View file

@ -19,42 +19,43 @@
"use strict"; "use strict";
const React = require("react"); 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 { useTextInput } = require("../lib/form");
const submit = require("../lib/submit"); const { TextInput } = require("../components/form/inputs");
const MutationButton = require("../components/form/mutation-button");
module.exports = function AdminActionPanel() { module.exports = function AdminActionPanel() {
const dispatch = Redux.useDispatch(); const daysField = useTextInput("days", { defaultValue: 30 });
const [days, setDays] = React.useState(30); const [mediaCleanup, mediaCleanupResult] = query.useMediaCleanupMutation();
const [errorMsg, setError] = React.useState(""); function submitMediaCleanup(e) {
const [statusMsg, setStatus] = React.useState(""); e.preventDefault();
mediaCleanup(daysField.value);
const removeMedia = submit( }
() => dispatch(api.admin.mediaCleanup(days)),
{setStatus, setError}
);
return ( return (
<> <>
<h1>Admin Actions</h1> <h1>Admin Actions</h1>
<div> <form onSubmit={submitMediaCleanup}>
<h2>Media cleanup</h2> <h2>Media cleanup</h2>
<p> <p>
Clean up remote media older than the specified number of days. Clean up remote media older than the specified number of days.
If the remote instance is still online they will be refetched when needed. If the remote instance is still online they will be refetched when needed.
Also cleans up unused headers and avatars from the media cache. Also cleans up unused headers and avatars from the media cache.
</p> </p>
<div> <TextInput
<label htmlFor="days">Days: </label> field={daysField}
<input id="days" type="number" value={days} onChange={(e) => setDays(e.target.value)}/> label="Days"
</div> type="number"
<Submit onClick={removeMedia} label="Remove media" errorMsg={errorMsg} statusMsg={statusMsg} /> min="0"
</div> placeholder="30"
/>
<MutationButton label="Remove old media" result={mediaCleanupResult} />
</form>
</> </>
); );
}; };

View file

@ -1,19 +1,19 @@
/* /*
GoToSocial GoToSocial
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify 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 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 the Free Software Foundation, either version 3 of the License, or
(at your option) any later version. (at your option) any later version.
This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details. GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License 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/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
"use strict"; "use strict";
@ -36,13 +36,15 @@ function useEmojiByCategory(emoji) {
), [emoji]); ), [emoji]);
} }
function CategorySelect({value, categoryState, setIsNew=() => {}, children}) { function CategorySelect({ field, children }) {
const { value, setIsNew } = field;
const { const {
data: emoji = [], data: emoji = [],
isLoading, isLoading,
isSuccess, isSuccess,
error error
} = query.useGetAllEmojiQuery({filter: "domain:local"}); } = query.useGetAllEmojiQuery({ filter: "domain:local" });
const emojiByCategory = useEmojiByCategory(emoji); const emojiByCategory = useEmojiByCategory(emoji);
@ -52,7 +54,7 @@ function CategorySelect({value, categoryState, setIsNew=() => {}, children}) {
const categoryItems = React.useMemo(() => { const categoryItems = React.useMemo(() => {
return syncpipe(emojiByCategory, [ return syncpipe(emojiByCategory, [
(_) => Object.keys(_), // just emoji category names (_) => 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 (_) => _.map((categoryName) => [ // map to input value, and selectable element with icon
categoryName, categoryName,
<> <>
@ -67,24 +69,24 @@ function CategorySelect({value, categoryState, setIsNew=() => {}, children}) {
if (value != undefined && isSuccess && value.trim().length > 0) { if (value != undefined && isSuccess && value.trim().length > 0) {
setIsNew(!categories.has(value.trim())); 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 if (error) { // fall back to plain text input, but this would almost certainly have caused a bigger error message elsewhere
return ( 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) { } else if (isLoading) {
return <input type="text" value="Loading categories..." disabled={true}/>; return <input type="text" value="Loading categories..." disabled={true} />;
} }
return ( return (
<ComboBox <ComboBox
state={categoryState} field={field}
items={categoryItems} items={categoryItems}
label="Category" label="Category"
placeHolder="e.g., reactions" placeholder="e.g., reactions"
children={children} children={children}
/> />
); );

View file

@ -19,155 +19,128 @@
"use strict"; "use strict";
const React = require("react"); const React = require("react");
const { useRoute, Link, Redirect } = require("wouter"); const { useRoute, Link, Redirect } = require("wouter");
const { CategorySelect } = require("../category-select");
const { useComboBoxInput, useFileInput } = require("../../../components/form");
const query = require("../../../lib/query"); const query = require("../../../lib/query");
const { useComboBoxInput, useFileInput, useValue } = require("../../../lib/form");
const { CategorySelect } = require("../category-select");
const useFormSubmit = require("../../../lib/form/submit");
const FakeToot = require("../../../components/fake-toot"); const FakeToot = require("../../../components/fake-toot");
const FormWithData = require("../../../lib/form/form-with-data");
const Loading = require("../../../components/loading"); 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"; const base = "/settings/custom-emoji/local";
module.exports = function EmojiDetailRoute() { module.exports = function EmojiDetailRoute() {
let [_match, params] = useRoute(`${base}/:emojiId`); let [_match, params] = useRoute(`${base}/:emojiId`);
if (params?.emojiId == undefined) { if (params?.emojiId == undefined) {
return <Redirect to={base}/>; return <Redirect to={base} />;
} else { } else {
return ( return (
<div className="emoji-detail"> <div className="emoji-detail">
<Link to={base}><a>&lt; go back</a></Link> <Link to={base}><a>&lt; go back</a></Link>
<EmojiDetailData emojiId={params.emojiId}/> <FormWithData dataQuery={query.useGetEmojiQuery} queryArg={params.emojiId} DataForm={EmojiDetailForm} />
</div> </div>
); );
} }
}; };
function EmojiDetailData({emojiId}) { function EmojiDetailForm({ data: emoji }) {
const {currentData: emoji, isLoading, error} = query.useGetEmojiQuery(emojiId); const form = {
id: useValue("id", emoji.id),
category: useComboBoxInput("category", { defaultValue: emoji.category }),
image: useFileInput("image", {
withPreview: true,
maxSize: 50 * 1024 // TODO: get from instance api
})
};
if (error) { const [modifyEmoji, result] = useFormSubmit(form, query.useEditEmojiMutation());
return (
<div className="error accent">
{error.status}: {error.data.error}
</div>
);
} else if (isLoading) {
return (
<div>
<Loading/>
</div>
);
} else {
return <EmojiDetail emoji={emoji}/>;
}
}
function EmojiDetail({emoji}) {
const [modifyEmoji, modifyResult] = query.useEditEmojiMutation();
const [isNewCategory, setIsNewCategory] = React.useState(false);
const [categoryState, _resetCategory, { category }] = useComboBoxInput("category", {defaultValue: emoji.category});
const [onFileChange, _resetFile, { image, imageURL, imageInfo }] = useFileInput("image", {
withPreview: true,
maxSize: 50 * 1024
});
function modifyCategory() {
modifyEmoji({id: emoji.id, category: category.trim()});
}
function modifyImage() {
modifyEmoji({id: emoji.id, image: image});
}
// Automatic submitting of category change
React.useEffect(() => { React.useEffect(() => {
if (category != emoji.category && !categoryState.open && !isNewCategory && category.trim().length > 0) { if (
console.log("updating to", category); form.category.hasChanged() &&
modifyEmoji({id: emoji.id, category: category.trim()}); !form.category.state.open &&
!form.category.isNew) {
modifyEmoji();
} }
}, [isNewCategory, category, categoryState.open, emoji.category, emoji.id, modifyEmoji]); /* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [form.category.hasChanged(), form.category.isNew, form.category.state.open]);
const [deleteEmoji, deleteResult] = query.useDeleteEmojiMutation();
if (deleteResult.isSuccess) {
return <Redirect to={base} />;
}
return ( return (
<> <>
<div className="emoji-header"> <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> <div>
<h2>{emoji.shortcode}</h2> <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> </div>
<div className="left-border"> <form onSubmit={modifyEmoji} className="left-border">
<h2>Modify this emoji {modifyResult.isLoading && "(processing..)"}</h2> <h2>Modify this emoji {result.isLoading && <Loading />}</h2>
{modifyResult.error && <div className="error">
{modifyResult.error.status}: {modifyResult.error.data.error}
</div>}
<div className="update-category"> <div className="update-category">
<CategorySelect <CategorySelect
value={category} field={form.category}
categoryState={categoryState}
setIsNew={setIsNewCategory}
> >
<button style={{visibility: (isNewCategory ? "initial" : "hidden")}} onClick={modifyCategory}> <MutationButton
Create name="create-category"
</button> label="Create"
result={result}
showError={false}
style={{ visibility: (form.category.isNew ? "initial" : "hidden") }}
/>
</CategorySelect> </CategorySelect>
</div> </div>
<div className="update-image"> <div className="update-image">
<b>Image</b> <FileInput
<div className="form-field file"> field={form.image}
<label className="file-input button" htmlFor="image"> label="Image"
Browse accept="image/png,image/gif"
</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> <MutationButton
name="image"
label="Replace image"
showError={false}
result={result}
/>
<FakeToot> <FakeToot>
Look at this new custom emoji <img Look at this new custom emoji <img
className="emoji" className="emoji"
src={imageURL ?? emoji.url} src={form.image.previewURL ?? emoji.url}
title={`:${emoji.shortcode}:`} title={`:${emoji.shortcode}:`}
alt={emoji.shortcode} alt={emoji.shortcode}
/> isn&apos;t it cool? /> isn&apos;t it cool?
</FakeToot> </FakeToot>
{result.error && <Error error={result.error} />}
{deleteResult.error && <Error error={deleteResult.error} />}
</div> </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>
);
} }

View file

@ -19,7 +19,7 @@
"use strict"; "use strict";
const React = require("react"); const React = require("react");
const {Switch, Route} = require("wouter"); const { Switch, Route } = require("wouter");
const EmojiOverview = require("./overview"); const EmojiOverview = require("./overview");
const EmojiDetail = require("./detail"); const EmojiDetail = require("./detail");
@ -28,13 +28,11 @@ const base = "/settings/custom-emoji/local";
module.exports = function CustomEmoji() { module.exports = function CustomEmoji() {
return ( return (
<> <Switch>
<Switch> <Route path={`${base}/:emojiId`}>
<Route path={`${base}/:emojiId`}> <EmojiDetail baseUrl={base} />
<EmojiDetail /> </Route>
</Route> <EmojiOverview baseUrl={base} />
<EmojiOverview /> </Switch>
</Switch>
</>
); );
}; };

View file

@ -18,101 +18,61 @@
"use strict"; "use strict";
const Promise = require('bluebird');
const React = require("react"); const React = require("react");
const FakeToot = require("../../../components/fake-toot"); const query = require("../../../lib/query");
const MutateButton = require("../../../components/mutation-button");
const { const {
useTextInput,
useFileInput, useFileInput,
useComboBoxInput useComboBoxInput
} = require("../../../components/form"); } = require("../../../lib/form");
const useShortcode = require("./use-shortcode");
const useFormSubmit = require("../../../lib/form/submit");
const {
TextInput, FileInput
} = require("../../../components/form/inputs");
const query = require("../../../lib/query");
const { CategorySelect } = require('../category-select'); const { CategorySelect } = require('../category-select');
const FakeToot = require("../../../components/fake-toot");
const MutationButton = require("../../../components/form/mutation-button");
const shortcodeRegex = /^[a-z0-9_]+$/; module.exports = function NewEmojiForm() {
const shortcode = useShortcode();
module.exports = function NewEmojiForm({ emoji }) { const image = useFileInput("image", {
const emojiCodes = React.useMemo(() => {
return new Set(emoji.map((e) => e.shortcode));
}, [emoji]);
const [addEmoji, result] = query.useAddEmojiMutation();
const [onFileChange, resetFile, { image, imageURL, imageInfo }] = useFileInput("image", {
withPreview: true, withPreview: true,
maxSize: 50 * 1024 maxSize: 50 * 1024 // TODO: get from instance api?
}); });
const [onShortcodeChange, resetShortcode, { shortcode, setShortcode, shortcodeRef }] = useTextInput("shortcode", { const category = useComboBoxInput("category");
validator: function validateShortcode(code) {
// technically invalid, but hacky fix to prevent validation error on page load
if (shortcode == "") {return "";}
if (emojiCodes.has(code)) { const [submitForm, result] = useFormSubmit({
return "Shortcode already in use"; shortcode, image, category
} }, query.useAddEmojiMutation());
if (code.length < 2 || code.length > 30) {
return "Shortcode must be between 2 and 30 characters";
}
if (code.toLowerCase() != code) {
return "Shortcode must be lowercase";
}
if (!shortcodeRegex.test(code)) {
return "Shortcode must only contain lowercase letters, numbers, and underscores";
}
return "";
}
});
const [categoryState, resetCategory, { category }] = useComboBoxInput("category");
React.useEffect(() => { React.useEffect(() => {
if (shortcode.length == 0) { if (shortcode.value.length == 0) {
if (image != undefined) { if (image.value != undefined) {
let [name, _ext] = image.name.split("."); let [name, _ext] = image.value.name.split(".");
setShortcode(name); 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) { /* We explicitly don't want to have 'shortcode' as a dependency here
if (e) { because we only want to change the shortcode to the filename if the field is empty
e.preventDefault(); 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]);
Promise.try(() => { let emojiOrShortcode = `:${shortcode.value}:`;
return addEmoji({
image,
shortcode,
category
}).unwrap();
}).then(() => {
resetFile();
resetShortcode();
resetCategory();
}).catch((e) => {
console.error("Emoji upload error:", e);
});
}
let emojiOrShortcode = `:${shortcode}:`; if (image.previewValue != undefined) {
if (imageURL != undefined) {
emojiOrShortcode = <img emojiOrShortcode = <img
className="emoji" className="emoji"
src={imageURL} src={image.previewValue}
title={`:${shortcode}:`} title={`:${shortcode}:`}
alt={shortcode} alt={shortcode}
/>; />;
@ -126,42 +86,22 @@ module.exports = function NewEmojiForm({ emoji }) {
Look at this new custom emoji {emojiOrShortcode} isn&apos;t it cool? Look at this new custom emoji {emojiOrShortcode} isn&apos;t it cool?
</FakeToot> </FakeToot>
<form onSubmit={uploadEmoji} className="form-flex"> <form onSubmit={submitForm} className="form-flex">
<div className="form-field file"> <FileInput
<label className="file-input button" htmlFor="image"> field={image}
Browse accept="image/png,image/gif"
</label>
{imageInfo}
<input
className="hidden"
type="file"
id="image"
name="Image"
accept="image/png,image/gif"
onChange={onFileChange}
/>
</div>
<div className="form-field text">
<label htmlFor="shortcode">
Shortcode, must be unique among the instance's local emoji
</label>
<input
type="text"
id="shortcode"
name="Shortcode"
ref={shortcodeRef}
onChange={onShortcodeChange}
value={shortcode}
/>
</div>
<CategorySelect
value={category}
categoryState={categoryState}
/> />
<MutateButton text="Upload emoji" result={result} /> <TextInput
field={shortcode}
label="Shortcode, must be unique among the instance's local emoji"
/>
<CategorySelect
field={category}
/>
<MutationButton label="Upload emoji" result={result} />
</form> </form>
</div> </div>
); );

View file

@ -1,25 +1,25 @@
/* /*
GoToSocial GoToSocial
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify 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 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 the Free Software Foundation, either version 3 of the License, or
(at your option) any later version. (at your option) any later version.
This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details. GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License 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/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
"use strict"; "use strict";
const React = require("react"); const React = require("react");
const {Link} = require("wouter"); const { Link } = require("wouter");
const NewEmojiForm = require("./new-emoji"); const NewEmojiForm = require("./new-emoji");
@ -27,33 +27,31 @@ const query = require("../../../lib/query");
const { useEmojiByCategory } = require("../category-select"); const { useEmojiByCategory } = require("../category-select");
const Loading = require("../../../components/loading"); const Loading = require("../../../components/loading");
const base = "/settings/custom-emoji/local"; module.exports = function EmojiOverview({ baseUrl }) {
module.exports = function EmojiOverview() {
const { const {
data: emoji = [], data: emoji = [],
isLoading, isLoading,
error error
} = query.useGetAllEmojiQuery({filter: "domain:local"}); } = query.useGetAllEmojiQuery({ filter: "domain:local" });
return ( return (
<> <>
<h1>Custom Emoji (local)</h1> <h1>Custom Emoji (local)</h1>
{error && {error &&
<div className="error accent">{error}</div> <div className="error accent">{error}</div>
} }
{isLoading {isLoading
? <Loading/> ? <Loading />
: <> : <>
<EmojiList emoji={emoji}/> <EmojiList emoji={emoji} baseUrl={baseUrl} />
<NewEmojiForm emoji={emoji}/> <NewEmojiForm emoji={emoji} />
</> </>
} }
</> </>
); );
}; };
function EmojiList({emoji}) { function EmojiList({ emoji, baseUrl }) {
const emojiByCategory = useEmojiByCategory(emoji); const emojiByCategory = useEmojiByCategory(emoji);
return ( return (
@ -62,24 +60,23 @@ function EmojiList({emoji}) {
<div className="list emoji-list"> <div className="list emoji-list">
{emoji.length == 0 && "No local emoji yet, add one below"} {emoji.length == 0 && "No local emoji yet, add one below"}
{Object.entries(emojiByCategory).map(([category, entries]) => { {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>
</div> </div>
); );
} }
function EmojiCategory({category, entries}) { function EmojiCategory({ category, entries, baseUrl }) {
return ( return (
<div className="entry"> <div className="entry">
<b>{category}</b> <b>{category}</b>
<div className="emoji-group"> <div className="emoji-group">
{entries.map((e) => { {entries.map((e) => {
return ( return (
<Link key={e.id} to={`${base}/${e.id}`}> <Link key={e.id} to={`${baseUrl}/${e.id}`}>
{/* <Link key={e.static_url} to={`${base}`}> */}
<a> <a>
<img src={e.url} alt={e.shortcode} title={`:${e.shortcode}:`}/> <img src={e.url} alt={e.shortcode} title={`:${e.shortcode}:`} />
</a> </a>
</Link> </Link>
); );

View file

@ -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 "";
}
});
};

View file

@ -31,7 +31,7 @@ module.exports = function RemoteEmoji() {
data: emoji = [], data: emoji = [],
isLoading, isLoading,
error error
} = query.useGetAllEmojiQuery({filter: "domain:local"}); } = query.useGetAllEmojiQuery({ filter: "domain:local" });
const emojiCodes = React.useMemo(() => { const emojiCodes = React.useMemo(() => {
return new Set(emoji.map((e) => e.shortcode)); return new Set(emoji.map((e) => e.shortcode));
@ -40,11 +40,11 @@ module.exports = function RemoteEmoji() {
return ( return (
<> <>
<h1>Custom Emoji (remote)</h1> <h1>Custom Emoji (remote)</h1>
{error && {error &&
<div className="error accent">{error}</div> <div className="error accent">{error}</div>
} }
{isLoading {isLoading
? <Loading/> ? <Loading />
: <> : <>
<ParseFromToot emoji={emoji} emojiCodes={emojiCodes} /> <ParseFromToot emoji={emoji} emojiCodes={emojiCodes} />
</> </>

View file

@ -18,57 +18,35 @@
"use strict"; "use strict";
const Promise = require("bluebird");
const React = require("react"); const React = require("react");
const Redux = require("react-redux");
const syncpipe = require("syncpipe"); const query = require("../../../lib/query");
const { const {
useTextInput, useTextInput,
useComboBoxInput useComboBoxInput,
} = require("../../../components/form"); useCheckListInput
} = require("../../../lib/form");
const useFormSubmit = require("../../../lib/form/submit");
const CheckList = require("../../../components/check-list");
const { CategorySelect } = require('../category-select'); const { CategorySelect } = require('../category-select');
const query = require("../../../lib/query"); const { TextInput } = require("../../../components/form/inputs");
const Loading = require("../../../components/loading"); const MutationButton = require("../../../components/form/mutation-button");
const { Error } = require("../../../components/error");
module.exports = function ParseFromToot({ emojiCodes }) { module.exports = function ParseFromToot({ emojiCodes }) {
const [searchStatus, { data, isLoading, isSuccess, error }] = query.useSearchStatusForEmojiMutation(); const [searchStatus, result] = query.useSearchStatusForEmojiMutation();
const instanceDomain = Redux.useSelector((state) => (new URL(state.oauth.instance).host));
const [onURLChange, _resetURL, { url }] = useTextInput("url"); 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) { function submitSearch(e) {
e.preventDefault(); e.preventDefault();
searchStatus(url); if (url.trim().length != 0) {
searchStatus(url);
}
} }
return ( return (
@ -87,233 +65,137 @@ module.exports = function ParseFromToot({ emojiCodes }) {
onChange={onURLChange} onChange={onURLChange}
value={url} value={url}
/> />
<button disabled={isLoading}> <button disabled={result.isLoading}>
<i className={[ <i className={[
"fa", "fa fa-fw",
(isLoading (result.isLoading
? "fa-refresh fa-spin" ? "fa-refresh fa-spin"
: "fa-search") : "fa-search")
].join(" ")} aria-hidden="true" title="Search"/> ].join(" ")} aria-hidden="true" title="Search" />
<span className="sr-only">Search</span> <span className="sr-only">Search</span>
</button> </button>
</div> </div>
{isLoading && <Loading/>}
{error && <div className="error">{error.data.error}</div>}
</div> </div>
</form> </form>
{searchResult} <SearchResult result={result} localEmojiCodes={emojiCodes} />
</div> </div>
); );
}; };
function makeEmojiState(emojiList, checked) { function SearchResult({ result, localEmojiCodes }) {
/* Return a new object, with a key for every emoji's shortcode, const { error, data, isSuccess, isError } = result;
And a value for it's checkbox `checked` state.
*/ if (!(isSuccess || isError)) {
return syncpipe(emojiList, [ return null;
(_) => _.map((emoji) => [emoji.shortcode, { }
checked,
valid: true if (error == "NONE_FOUND") {
}]), return "No results found";
(_) => Object.fromEntries(_) } 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} />;
}
if (data.list.length == 0) {
return <b>This {data.type == "statuses" ? "toot" : "account"} doesn't use any custom emoji</b>;
}
return (
<CopyEmojiForm
localEmojiCodes={localEmojiCodes}
type={data.type}
domain={data.domain}
emojiList={data.list}
/>
);
} }
function updateEmojiState(emojiState, checked) { function CopyEmojiForm({ localEmojiCodes, type, emojiList }) {
/* Create a new object with all emoji entries' checked state updated */ const form = {
return syncpipe(emojiState, [ selectedEmoji: useCheckListInput("selectedEmoji", {
(_) => Object.entries(emojiState), entries: emojiList,
(_) => _.map(([key, val]) => [key, { uniqueKey: "shortcode"
...val, }),
checked category: useComboBoxInput("category")
}]), };
(_) => Object.fromEntries(_)
]);
}
function CopyEmojiForm({ localEmojiCodes, type, domain, emojiList }) { const [formSubmit, result] = useFormSubmit(form, query.usePatchRemoteEmojisMutation(), { changedOnly: false });
const [patchRemoteEmojis, patchResult] = query.usePatchRemoteEmojisMutation();
const [err, setError] = React.useState();
const toggleAllRef = React.useRef(null); const buttonsInactive = form.selectedEmoji.someSelected
const [toggleAllState, setToggleAllState] = React.useState(0); ? {}
const [emojiState, setEmojiState] = React.useState(makeEmojiState(emojiList, false)); : {
const [someSelected, setSomeSelected] = React.useState(false); disabled: true,
title: "No emoji selected, cannot perform any actions"
const [categoryState, resetCategory, { category }] = useComboBoxInput("category"); };
React.useEffect(() => {
if (emojiList != undefined) {
setEmojiState(makeEmojiState(emojiList, false));
}
}, [emojiList]);
React.useEffect(() => {
/* Updates (un)check all checkbox, based on shortcode checkboxes
Can be 0 (not checked), 1 (checked) or 2 (indeterminate)
*/
if (toggleAllRef.current == null) {
return;
}
let values = Object.values(emojiState);
/* one or more boxes are checked */
let some = values.some((v) => v.checked);
let all = false;
if (some) {
/* there's not at least one unchecked box */
all = !values.some((v) => v.checked == false);
}
setSomeSelected(some);
if (some && !all) {
setToggleAllState(2);
toggleAllRef.current.indeterminate = true;
} else {
setToggleAllState(all ? 1 : 0);
toggleAllRef.current.indeterminate = false;
}
}, [emojiState, toggleAllRef]);
function updateEmoji(shortcode, value) {
setEmojiState({
...emojiState,
[shortcode]: {
...emojiState[shortcode],
...value
}
});
}
function toggleAll(e) {
let selectAll = e.target.checked;
if (toggleAllState == 2) { // indeterminate
selectAll = false;
}
setEmojiState(updateEmojiState(emojiState, selectAll));
setToggleAllState(selectAll);
}
function submit(action) {
Promise.try(() => {
setError(null);
const selectedShortcodes = syncpipe(emojiState, [
(_) => Object.entries(_),
(_) => _.filter(([_shortcode, entry]) => entry.checked),
(_) => _.map(([shortcode, entry]) => {
if (action == "copy" && !entry.valid) {
throw `One or more selected emoji have non-unique shortcodes (${shortcode}), unselect them or pick a different local shortcode`;
}
return {
shortcode,
localShortcode: entry.shortcode
};
})
]);
return patchRemoteEmojis({
action,
domain,
list: selectedShortcodes,
category
}).unwrap();
}).then(() => {
setEmojiState(makeEmojiState(emojiList, false));
resetCategory();
}).catch((e) => {
if (Array.isArray(e)) {
setError(e.map(([shortcode, msg]) => (
<div key={shortcode}>
{shortcode}: <span style={{ fontWeight: "initial" }}>{msg}</span>
</div>
)));
} else {
setError(e);
}
});
}
return ( return (
<div className="parsed"> <div className="parsed">
<span>This {type == "statuses" ? "toot" : "account"} uses the following custom emoji, select the ones you want to copy/disable:</span> <span>This {type == "statuses" ? "toot" : "account"} uses the following custom emoji, select the ones you want to copy/disable:</span>
<div className="emoji-list"> <form onSubmit={formSubmit}>
<label className="header"> <CheckList
<input field={form.selectedEmoji}
ref={toggleAllRef} Component={EmojiEntry}
type="checkbox" localEmojiCodes={localEmojiCodes}
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 <CategorySelect
value={category} field={form.category}
categoryState={categoryState} />
/>
<div className="action-buttons row"> <div className="action-buttons row">
<button disabled={!someSelected} onClick={() => submit("copy")}>{patchResult.isLoading ? "Processing..." : "Copy to local emoji"}</button> <MutationButton name="copy" label="Copy to local emoji" result={result} showError={false} {...buttonsInactive} />
<button disabled={!someSelected} onClick={() => submit("disable")} className="danger">{patchResult.isLoading ? "Processing..." : "Disable"}</button> <MutationButton name="disable" label="Disable" result={result} className="button danger" showError={false} {...buttonsInactive} />
</div> </div>
{err && <div className="error"> {result.error && (
{err} Array.isArray(result.error)
</div>} ? <ErrorList errors={result.error} />
{patchResult.isSuccess && <div> : <Error error={result.error} />
Action applied to {patchResult.data.length} emoji )}
</div>} </form>
</div> </div>
); );
} }
function EmojiEntry({ emoji, localEmojiCodes, updateEmoji, checked }) { function ErrorList({ errors }) {
const [onShortcodeChange, _resetShortcode, { shortcode, shortcodeRef, shortcodeValid }] = useTextInput("shortcode", { 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({ entry: emoji, localEmojiCodes, onChange }) {
const shortcodeField = useTextInput("shortcode", {
defaultValue: emoji.shortcode, defaultValue: emoji.shortcode,
validator: function validateShortcode(code) { validator: function validateShortcode(code) {
return (checked && localEmojiCodes.has(code)) return (emoji.checked && localEmojiCodes.has(code))
? "Shortcode already in use" ? "Shortcode already in use"
: ""; : "";
} }
}); });
React.useEffect(() => { React.useEffect(() => {
updateEmoji({ valid: shortcodeValid }); onChange({ valid: shortcodeField.valid });
/* eslint-disable-next-line react-hooks/exhaustive-deps */ /* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [shortcodeValid]); }, [shortcodeField.valid]);
return ( 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} /> <img className="emoji" src={emoji.url} title={emoji.shortcode} />
<input <TextInput
type="text" field={shortcodeField}
id="shortcode"
name="Shortcode"
ref={shortcodeRef}
onChange={(e) => { onChange={(e) => {
onShortcodeChange(e); shortcodeField.onChange(e);
updateEmoji({ shortcode: e.target.value, checked: true }); onChange({ shortcode: e.target.value, checked: true });
}} }}
value={shortcode}
/> />
</label> </>
); );
} }

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
</>
);
}

View file

@ -19,17 +19,26 @@
"use strict"; "use strict";
const React = require("react"); const React = require("react");
const { Switch, Route } = require("wouter");
module.exports = function Submit({onClick, label, errorMsg, statusMsg}) { const baseUrl = `/settings/admin/federation`;
const InstanceOverview = require("./overview");
const InstanceDetail = require("./detail");
const InstanceImportExport = require("./import-export");
module.exports = function Federation({ }) {
return ( return (
<div className="messagebutton"> <Switch>
<button type="submit" onClick={onClick}>{ label }</button> <Route path={`${baseUrl}/import-export/:list?`}>
{errorMsg.length > 0 && <InstanceImportExport />
<div className="error accent">{errorMsg}</div> </Route>
}
{statusMsg.length > 0 && <Route path={`${baseUrl}/:domain`}>
<div className="accent">{statusMsg}</div> <InstanceDetail baseUrl={baseUrl} />
} </Route>
</div>
<InstanceOverview baseUrl={baseUrl} />
</Switch>
); );
}; };

View file

@ -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>
</>
);
};

View file

@ -19,88 +19,105 @@
"use strict"; "use strict";
const React = require("react"); 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 {
const submit = require("../lib/submit"); useTextInput,
useFileInput
} = require("../lib/form");
const adminActions = require("../redux/reducers/instances").actions; const useFormSubmit = require("../lib/form/submit");
const { const {
TextInput, TextInput,
TextArea, TextArea,
File FileInput
} = require("../components/form-fields").formFields(adminActions.setAdminSettingsVal, (state) => state.instances.adminSettings); } = require("../components/form/inputs");
const FormWithData = require("../lib/form/form-with-data");
const MutationButton = require("../components/form/mutation-button");
module.exports = function AdminSettings() { module.exports = function AdminSettings() {
const dispatch = Redux.useDispatch(); return (
const instance = Redux.useSelector(state => state.instances.adminSettings); <FormWithData
dataQuery={query.useInstanceQuery}
const [errorMsg, setError] = React.useState(""); DataForm={AdminSettingsForm}
const [statusMsg, setStatus] = React.useState(""); />
const updateSettings = submit(
() => dispatch(api.admin.updateInstance()),
{setStatus, setError}
); );
};
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 ( return (
<div> <form onSubmit={submitForm}>
<h1>Instance Settings</h1> <h1>Instance Settings</h1>
<TextInput <TextInput
id="title" field={form.title}
name="Title" label="Title"
placeHolder="My GoToSocial instance" placeholder="My GoToSocial instance"
/> />
<div className="file-upload"> <div className="file-upload">
<h3>Instance thumbnail</h3> <h3>Instance thumbnail</h3>
<div> <div>
<img className="preview avatar" src={instance.thumbnail} alt={instance.thumbnail ? `Thumbnail image for the instance` : "No instance thumbnail image set"} /> <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")} />
<File <FileInput
id="thumbnail" field={form.thumbnail}
fileType="image/*" accept="image/*"
/> />
</div> </div>
</div> </div>
<TextInput <TextInput
id="thumbnail_description" field={form.thumbnailDesc}
name="Instance thumbnail description" label="Instance thumbnail description"
placeHolder="A cute little picture of a smiling sloth." placeholder="A cute drawing of a smiling sloth."
/> />
<TextArea <TextArea
id="short_description" field={form.shortDesc}
name="Short description" label="Short description"
placeHolder="A small testing instance for the GoToSocial alpha." placeholder="A small testing instance for the GoToSocial alpha software."
/> />
<TextArea <TextArea
id="description" field={form.description}
name="Full description" label="Full description"
placeHolder="A small testing instance for the GoToSocial alpha." placeholder="A small testing instance for the GoToSocial alpha software. Just trying it out, my main instance is https://example.com"
/> />
<TextInput <TextInput
id="contact_account.username" field={form.contactUser}
name="Contact user (local account username)" label="Contact user (local account username)"
placeHolder="admin" placeholder="admin"
/> />
<TextInput <TextInput
id="email" field={form.contactEmail}
name="Contact email" label="Contact email"
placeHolder="admin@example.com" placeholder="admin@example.com"
/> />
<TextArea <TextArea
id="terms" field={form.terms}
name="Terms & Conditions" label="Terms & Conditions"
placeHolder="" placeholder=""
/> />
<Submit onClick={updateSettings} label="Save" errorMsg={errorMsg} statusMsg={statusMsg} /> <MutationButton label="Save" result={result} />
</div> </form>
); );
}; }

View file

@ -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>
);
}
};

View file

@ -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>
);
};

View file

@ -21,7 +21,7 @@
const React = require("react"); const React = require("react");
const { Link } = require("wouter"); const { Link } = require("wouter");
module.exports = function BackButton({to}) { module.exports = function BackButton({ to }) {
return ( return (
<Link to={to}> <Link to={to}>
<a className="button">&lt; back</a> <a className="button">&lt; back</a>

View file

@ -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>
);
}

View file

@ -26,21 +26,21 @@ const {
ComboboxPopover, ComboboxPopover,
} = require("ariakit/combobox"); } = require("ariakit/combobox");
module.exports = function ComboBox({state, items, label, placeHolder, children}) { module.exports = function ComboBox({ field, items, label, children, ...inputProps }) {
return ( return (
<div className="form-field combobox-wrapper"> <div className="form-field combobox-wrapper">
<label> <label>
{label} {label}
<div className="row"> <div className="row">
<Combobox <Combobox
state={state} state={field.state}
placeholder={placeHolder}
className="combobox input" className="combobox input"
{...inputProps}
/> />
{children} {children}
</div> </div>
</label> </label>
<ComboboxPopover state={state} className="popover"> <ComboboxPopover state={field.state} className="popover">
{items.map(([key, value]) => ( {items.map(([key, value]) => (
<ComboboxItem className="combobox-item" key={key} value={key}> <ComboboxItem className="combobox-item" key={key} value={key}>
{value} {value}

View file

@ -20,7 +20,7 @@
const React = require("react"); const React = require("react");
module.exports = function ErrorFallback({error, resetErrorBoundary}) { function ErrorFallback({ error, resetErrorBoundary }) {
return ( return (
<div className="error"> <div className="error">
<p> <p>
@ -28,7 +28,7 @@ module.exports = function ErrorFallback({error, resetErrorBoundary}) {
<a href="https://github.com/superseriousbusiness/gotosocial/issues">GoToSocial issue tracker</a> <a href="https://github.com/superseriousbusiness/gotosocial/issues">GoToSocial issue tracker</a>
{" or "} {" or "}
<a href="https://matrix.to/#/#gotosocial-help:superseriousbusiness.org">Matrix support room</a>. <a href="https://matrix.to/#/#gotosocial-help:superseriousbusiness.org">Matrix support room</a>.
<br/>Include the details below: <br />Include the details below:
</p> </p>
<pre> <pre>
{error.name}: {error.message} {error.name}: {error.message}
@ -41,4 +41,43 @@ module.exports = function ErrorFallback({error, resetErrorBoundary}) {
</p> </p>
</div> </div>
); );
}; }
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 };

View file

@ -19,24 +19,21 @@
"use strict"; "use strict";
const React = require("react"); 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 return ( // Keep in sync with web/template/profile.tmpl
<div className="profile"> <div className="profile">
<div className="headerimage"> <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>
<div className="basic"> <div className="basic">
<div id="profile-basic-filler2"></div> <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> <span className="avatar"><img className="avatarpreview" src={avatar} alt={avatar ? `avatar image for ${username}` : "None set"} /></span>
<div className="displayname">{account.display_name.trim().length > 0 ? account.display_name : account.username}</div> <div className="displayname">{display_name.trim().length > 0 ? display_name : username}</div>
<div className="usernamecontainer"> <div className="usernamecontainer">
<div className="username"><span>@{account.username}</span></div> <div className="username"><span>@{username}</span></div>
{(account.role && account.role != "user") && {(role && role != "user") &&
<div className={`role ${account.role}`}>{account.role}</div> <div className={`role ${role}`}>{role}</div>
} }
</div> </div>
</div> </div>

View file

@ -19,16 +19,21 @@
"use strict"; "use strict";
const React = require("react"); const React = require("react");
const Redux = require("react-redux");
module.exports = function FakeToot({children}) { const query = require("../lib/query");
const account = Redux.useSelector((state) => state.user.profile);
module.exports = function FakeToot({ children }) {
const { data: account = {
avatar: "/assets/default_avatars/GoToSocial_icon1.png",
display_name: "",
username: ""
} } = query.useVerifyCredentialsQuery();
return ( return (
<div className="toot expanded"> <div className="toot expanded">
<div className="contentgrid"> <div className="contentgrid">
<span className="avatar"> <span className="avatar">
<img src={account.avatar} alt=""/> <img src={account.avatar} alt="" />
</span> </span>
<span className="displayname">{account.display_name.trim().length > 0 ? account.display_name : account.username}</span> <span className="displayname">{account.display_name.trim().length > 0 ? account.display_name : account.username}</span>
<span className="username">@{account.username}</span> <span className="username">@{account.username}</span>

View file

@ -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
};

View file

@ -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"))
};

View file

@ -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
};

View file

@ -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>
);
};

View file

@ -22,6 +22,6 @@ const React = require("react");
module.exports = function Loading() { module.exports = function Loading() {
return ( 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" />
); );
}; };

View file

@ -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>
);
};

View file

@ -21,7 +21,7 @@
const React = require("react"); const React = require("react");
const { Link, useRoute } = require("wouter"); const { Link, useRoute } = require("wouter");
module.exports = function NavButton({href, name}) { module.exports = function NavButton({ href, name }) {
const [isActive] = useRoute(`${href}/:anything?`); const [isActive] = useRoute(`${href}/:anything?`);
return ( return (
<Link href={href}> <Link href={href}>

View file

@ -18,20 +18,16 @@
"use strict"; "use strict";
const Promise = require("bluebird");
const React = require("react"); const React = require("react");
const ReactDom = require("react-dom/client"); const ReactDom = require("react-dom/client");
const Redux = require("react-redux");
const { Switch, Route, Redirect } = require("wouter");
const { Provider } = require("react-redux"); const { Provider } = require("react-redux");
const { PersistGate } = require("redux-persist/integration/react"); const { PersistGate } = require("redux-persist/integration/react");
const { Switch, Route, Redirect } = require("wouter");
const query = require("./lib/query");
const { store, persistor } = require("./redux"); const { store, persistor } = require("./redux");
const api = require("./lib/api"); const AuthorizationGate = require("./components/authorization");
const oauth = require("./redux/reducers/oauth").actions;
const { AuthenticationError } = require("./lib/errors");
const Login = require("./components/login");
const Loading = require("./components/loading"); const Loading = require("./components/loading");
require("./style.css"); require("./style.css");
@ -46,7 +42,7 @@ const nav = {
adminOnly: true, adminOnly: true,
"Instance Settings": require("./admin/settings.js"), "Instance Settings": require("./admin/settings.js"),
"Actions": require("./admin/actions"), "Actions": require("./admin/actions"),
"Federation": require("./admin/federation.js"), "Federation": require("./admin/federation"),
}, },
"Custom Emoji": { "Custom Emoji": {
adminOnly: true, adminOnly: true,
@ -57,123 +53,37 @@ const nav = {
const { sidebar, panelRouter } = require("./lib/get-views")(nav); const { sidebar, panelRouter } = require("./lib/get-views")(nav);
function App() { function App({ account }) {
const dispatch = Redux.useDispatch(); const isAdmin = account.role == "admin";
const [logoutQuery] = query.useLogoutMutation();
const { loginState, isAdmin } = Redux.useSelector((state) => state.oauth); return (
const reduxTempStatus = Redux.useSelector((state) => state.temporary.status); <>
<div className="sidebar">
const [errorMsg, setErrorMsg] = React.useState(); {sidebar.all}
const [tokenChecked, setTokenChecked] = React.useState(false); {isAdmin && sidebar.admin}
<button className="logout" onClick={logoutQuery}>
React.useEffect(() => { Log out
if (loginState == "login" || loginState == "callback") { </button>
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>
</div> </div>
); <section className="with-sidebar">
} <Switch>
{panelRouter.all}
const LogoutElement = ( {isAdmin && panelRouter.admin}
<button className="logout" onClick={() => { dispatch(api.oauth.logout()); }}> <Route>
Log out <Redirect to="/settings/user" />
</button> </Route>
</Switch>
</section>
</>
); );
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>
);
}
} }
function Main() { function Main() {
return ( return (
<Provider store={store}> <Provider store={store}>
<PersistGate loading={<section><Loading/></section>} persistor={persistor}> <PersistGate loading={<section><Loading /></section>} persistor={persistor}>
<App /> <AuthorizationGate App={App} />
</PersistGate> </PersistGate>
</Provider> </Provider>
); );

View file

@ -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;
};

View file

@ -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
};

View file

@ -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());
};
}
};
};

View file

@ -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});
}
};
};

View file

@ -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"),
};

View file

@ -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
});
};

View file

@ -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
}
});
};

View file

@ -18,9 +18,13 @@
"use strict"; "use strict";
const React = require("react");
const { useComboboxState } = require("ariakit/combobox"); const { useComboboxState } = require("ariakit/combobox");
module.exports = function useComboBoxInput({name, Name}, {validator, defaultValue} = {}) { module.exports = function useComboBoxInput({ name, Name }, { defaultValue } = {}) {
const [isNew, setIsNew] = React.useState(false);
const state = useComboboxState({ const state = useComboboxState({
defaultValue, defaultValue,
gutter: 0, gutter: 0,
@ -31,11 +35,22 @@ module.exports = function useComboBoxInput({name, Name}, {validator, defaultValu
state.setValue(""); state.setValue("");
} }
return [ return Object.assign([
state, state,
reset, reset,
{ {
[name]: state.value, [name]: state.value,
name,
[`${name}IsNew`]: isNew,
[`set${Name}IsNew`]: setIsNew
} }
]; ], {
name,
state,
value: state.value,
hasChanged: () => state.value != defaultValue,
isNew,
setIsNew,
reset
});
}; };

View file

@ -21,11 +21,11 @@
const React = require("react"); const React = require("react");
const prettierBytes = require("prettier-bytes"); const prettierBytes = require("prettier-bytes");
module.exports = function useFileInput({name, _Name}, { module.exports = function useFileInput({ name, _Name }, {
withPreview, withPreview,
maxSize, maxSize,
initialInfo = "no file selected" initialInfo = "no file selected"
}) { } = {}) {
const [file, setFile] = React.useState(); const [file, setFile] = React.useState();
const [imageURL, setImageURL] = React.useState(); const [imageURL, setImageURL] = React.useState();
const [info, setInfo] = React.useState(); const [info, setInfo] = React.useState();
@ -40,7 +40,7 @@ module.exports = function useFileInput({name, _Name}, {
if (withPreview) { if (withPreview) {
setImageURL(URL.createObjectURL(file)); setImageURL(URL.createObjectURL(file));
} }
let size = prettierBytes(file.size); let size = prettierBytes(file.size);
if (maxSize && file.size > maxSize) { if (maxSize && file.size > maxSize) {
size = <span className="error-text">{size}</span>; size = <span className="error-text">{size}</span>;
@ -61,18 +61,31 @@ module.exports = function useFileInput({name, _Name}, {
setInfo(); setInfo();
} }
return [ const infoComponent = (
<span className="form-info">
{info
? info
: initialInfo
}
</span>
);
// Array / Object hybrid, for easier access in different contexts
return Object.assign([
onChange, onChange,
reset, reset,
{ {
[name]: file, [name]: file,
[`${name}URL`]: imageURL, [`${name}URL`]: imageURL,
[`${name}Info`]: <span className="form-info"> [`${name}Info`]: infoComponent,
{info
? info
: initialInfo
}
</span>
} }
]; ], {
onChange,
reset,
name,
value: file,
previewValue: imageURL,
hasChanged: () => file != undefined,
infoComponent
});
}; };

View file

@ -20,23 +20,20 @@
const React = require("react"); const React = require("react");
module.exports = function MutateButton({text, result}) { const Loading = require("../../components/loading");
let buttonText = text;
if (result.isLoading) { // Wrap Form component inside component that fires the RTK Query call,
buttonText = "Processing..."; // 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} />;
} }
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>
);
}; };

View file

@ -18,25 +18,29 @@
"use strict"; "use strict";
const {createSlice} = require("@reduxjs/toolkit"); function capitalizeFirst(str) {
const d = require("dotty"); return str.slice(0, 1).toUpperCase() + str.slice(1);
}
module.exports = createSlice({ function makeHook(func) {
name: "instances", return (name, ...args) => func({
initialState: { name,
info: {}, Name: capitalizeFirst(name)
}, }, ...args);
reducers: { }
setNamedInstanceInfo: (state, {payload}) => {
let [key, info] = payload; module.exports = {
state.info[key] = info; useTextInput: makeHook(require("./text")),
}, useFileInput: makeHook(require("./file")),
setInstanceInfo: (state, {payload}) => { useBoolInput: makeHook(require("./bool")),
state.current = payload; useRadioInput: makeHook(require("./radio")),
state.adminSettings = payload; useComboBoxInput: makeHook(require("./combo-box")),
}, useCheckListInput: makeHook(require("./check-list")),
setAdminSettingsVal: (state, {payload: [key, val]}) => { useValue: function (name, value) {
d.put(state.adminSettings, key, val); return {
} name,
value,
hasChanged: () => true // always included
};
} }
}); };

View file

@ -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
});
};

View file

@ -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
}
];
};

View file

@ -20,7 +20,7 @@
const React = require("react"); const React = require("react");
module.exports = function useTextInput({name, Name}, {validator, defaultValue=""} = {}) { module.exports = function useTextInput({ name, Name }, { validator, defaultValue = "", dontReset = false } = {}) {
const [text, setText] = React.useState(defaultValue); const [text, setText] = React.useState(defaultValue);
const [valid, setValid] = React.useState(true); const [valid, setValid] = React.useState(true);
const textRef = React.useRef(null); const textRef = React.useRef(null);
@ -31,26 +31,37 @@ module.exports = function useTextInput({name, Name}, {validator, defaultValue=""
} }
function reset() { function reset() {
setText(""); if (!dontReset) {
setText(defaultValue);
}
} }
React.useEffect(() => { React.useEffect(() => {
if (validator) { if (validator && textRef.current) {
let res = validator(text); let res = validator(text);
setValid(res == ""); setValid(res == "");
textRef.current.setCustomValidity(res); textRef.current.setCustomValidity(res);
textRef.current.reportValidity();
} }
}, [text, textRef, validator]); }, [text, textRef, validator]);
return [ // Array / Object hybrid, for easier access in different contexts
return Object.assign([
onChange, onChange,
reset, reset,
{ {
[name]: text, [name]: text,
[`${name}Ref`]: textRef, [`${name}Ref`]: textRef,
[`set${Name}`]: setText, [`set${Name}`]: setText,
[`${name}Valid`]: valid [`${name}Valid`]: valid,
} }
]; ], {
onChange,
reset,
name,
value: text,
ref: textRef,
setter: setText,
valid,
hasChanged: () => text != defaultValue
});
}; };

View file

@ -22,7 +22,7 @@ const React = require("react");
const { Link, Route, Redirect } = require("wouter"); const { Link, Route, Redirect } = require("wouter");
const { ErrorBoundary } = require("react-error-boundary"); const { ErrorBoundary } = require("react-error-boundary");
const ErrorFallback = require("../components/error"); const { ErrorFallback } = require("../components/error");
const NavButton = require("../components/nav-button"); const NavButton = require("../components/nav-button");
function urlSafe(str) { function urlSafe(str) {
@ -64,7 +64,7 @@ module.exports = function getViews(struct) {
} }
panelRouterEl.push(( panelRouterEl.push((
<Route path={`${url}/:page?`} key={url}> <Route path={`${url}/:page*`} key={url}>
<ErrorBoundary FallbackComponent={ErrorFallback} onReset={() => { }}> <ErrorBoundary FallbackComponent={ErrorFallback} onReset={() => { }}>
{/* FIXME: implement onReset */} {/* FIXME: implement onReset */}
<ViewComponent /> <ViewComponent />

View file

@ -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
};
}

View file

@ -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];
}
});
};
}

View file

@ -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 });

View file

@ -1,35 +1,57 @@
/* /*
GoToSocial GoToSocial
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify 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 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 the Free Software Foundation, either version 3 of the License, or
(at your option) any later version. (at your option) any later version.
This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details. GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License 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/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
"use strict"; "use strict";
const { createApi, fetchBaseQuery } = require("@reduxjs/toolkit/query/react"); 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) { function instanceBasedQuery(args, api, extraOptions) {
const state = api.getState(); const state = api.getState();
const {instance, token} = state.oauth; const { instance, token } = state.oauth;
if (args.baseUrl == undefined) { if (args.baseUrl == undefined) {
args.baseUrl = instance; 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) { if (args.asForm) {
delete args.asForm; delete args.asForm;
args.body = convertToForm(args.body); args.body = convertToForm(args.body);
@ -50,6 +72,12 @@ function instanceBasedQuery(args, api, extraOptions) {
module.exports = createApi({ module.exports = createApi({
reducerPath: "api", reducerPath: "api",
baseQuery: instanceBasedQuery, baseQuery: instanceBasedQuery,
tagTypes: ["Emojis"], tagTypes: ["Auth"],
endpoints: () => ({}) endpoints: (build) => ({
instance: build.query({
query: () => ({
url: `/api/v1/instance`
})
})
})
}); });

View file

@ -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});

View file

@ -1,24 +1,26 @@
/* /*
GoToSocial GoToSocial
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify 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 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 the Free Software Foundation, either version 3 of the License, or
(at your option) any later version. (at your option) any later version.
This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details. GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License 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/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
"use strict"; "use strict";
module.exports = { module.exports = {
...require("./base"), ...require("./base"),
...require("./custom-emoji.js") ...require("./oauth"),
...require("./user"),
...require("./admin")
}; };

View file

@ -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 });
}));
});
}
};
};
}

View file

@ -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 });

View file

@ -18,31 +18,27 @@
"use strict"; "use strict";
const Promise = require("bluebird"); const { replaceCacheOnMutation } = require("./lib");
const base = require("./base");
module.exports = function submit(func, { const endpoints = (build) => ({
setStatus, setError, updateCredentials: build.mutation({
startStatus="PATCHing", successStatus="Saved!", query: (formData) => ({
onSuccess, method: "PATCH",
onError url: `/api/v1/accounts/update_credentials`,
}) { asForm: true,
return function() { body: formData,
setStatus(startStatus); discardEmpty: true
setError(""); }),
return Promise.try(() => { ...replaceCacheOnMutation("verifyCredentials")
return func(); }),
}).then(() => { passwordChange: build.mutation({
setStatus(successStatus); query: (data) => ({
if (onSuccess != undefined) { method: "POST",
return onSuccess(); url: `/api/v1/user/password_change`,
} body: data
}).catch((e) => { })
setError(e.message); })
setStatus(""); });
console.error(e);
if (onError != undefined) { module.exports = base.injectEndpoints({ endpoints });
onError(e);
}
});
};
};

View file

@ -1,19 +1,19 @@
/* /*
GoToSocial GoToSocial
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify 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 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 the Free Software Foundation, either version 3 of the License, or
(at your option) any later version. (at your option) any later version.
This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details. GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License 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/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
"use strict"; "use strict";
@ -34,18 +34,14 @@ const {
const query = require("../lib/query/base"); const query = require("../lib/query/base");
const combinedReducers = combineReducers({ const combinedReducers = combineReducers({
oauth: require("./reducers/oauth").reducer, oauth: require("./oauth").reducer,
instances: require("./reducers/instances").reducer,
temporary: require("./reducers/temporary").reducer,
user: require("./reducers/user").reducer,
admin: require("./reducers/admin").reducer,
[query.reducerPath]: query.reducer [query.reducerPath]: query.reducer
}); });
const persistedReducer = persistReducer({ const persistedReducer = persistReducer({
key: "gotosocial-settings", key: "gotosocial-settings",
storage: require("redux-persist/lib/storage").default, 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"], whitelist: ["oauth"],
}, combinedReducers); }, combinedReducers);
@ -54,7 +50,7 @@ const store = configureStore({
middleware: (getDefaultMiddleware) => { middleware: (getDefaultMiddleware) => {
return getDefaultMiddleware({ return getDefaultMiddleware({
serializableCheck: { serializableCheck: {
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER, "temporary/setScrollElement"] ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER]
} }
}).concat(query.middleware); }).concat(query.middleware);
} }

View file

@ -18,35 +18,31 @@
"use strict"; "use strict";
const {createSlice} = require("@reduxjs/toolkit"); const { createSlice } = require("@reduxjs/toolkit");
module.exports = createSlice({ module.exports = createSlice({
name: "oauth", name: "oauth",
initialState: { initialState: {
loginState: 'none', loginState: 'none'
}, },
reducers: { reducers: {
setInstance: (state, {payload}) => { setInstance: (state, { payload }) => {
state.instance = payload; return {
...state,
...payload /* overrides instance, registration keys */
};
}, },
setRegistration: (state, {payload}) => { authorize: (state) => {
state.registration = payload; state.loginState = "callback";
}, },
setLoginState: (state, {payload}) => { setToken: (state, { payload }) => {
state.loginState = payload;
},
login: (state, {payload}) => {
state.token = `${payload.token_type} ${payload.access_token}`; state.token = `${payload.token_type} ${payload.access_token}`;
state.loginState = "login"; state.loginState = "login";
}, },
remove: (state, {_payload}) => { remove: (state, { _payload }) => {
delete state.token; delete state.token;
delete state.registration; delete state.registration;
delete state.isAdmin; state.loginState = "logout";
state.loginState = "none";
},
setAdmin: (state, {payload}) => {
state.isAdmin = payload;
} }
} }
}); });

View file

@ -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");
}
}
});

View file

@ -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);
}
}
});

View file

@ -16,6 +16,11 @@
along with this program. If not, see <http://www.gnu.org/licenses/>. 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 { body {
grid-template-rows: auto 1fr; grid-template-rows: auto 1fr;
} }
@ -40,7 +45,7 @@ section {
border-top-left-radius: 0; border-top-left-radius: 0;
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
& > div { & > div, & > form {
border-left: 0.2rem solid $border-accent; border-left: 0.2rem solid $border-accent;
padding-left: 0.4rem; padding-left: 0.4rem;
display: flex; display: flex;
@ -50,7 +55,7 @@ section {
h2 { h2 {
margin: 0; margin: 0;
margin-bottom: 0.5rem; margin-top: 0.1rem;
} }
&:only-child { &:only-child {
@ -213,7 +218,7 @@ input, select, textarea {
) !important; ) !important;
} }
section.with-sidebar > div { section.with-sidebar > div, section.with-sidebar > form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
@ -223,19 +228,17 @@ section.with-sidebar > div {
line-height: 1.5rem; line-height: 1.5rem;
} }
button {
width: auto;
align-self: flex-start;
line-height: 1.5rem;
}
input[type=checkbox] { input[type=checkbox] {
justify-self: start; justify-self: start;
width: initial; width: initial;
} }
input:read-only {
border: none;
}
input:invalid {
border-color: red;
}
textarea { textarea {
width: 100%; width: 100%;
} }
@ -337,15 +340,24 @@ section.with-sidebar > div {
} }
} }
form {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-field label { .form-field label {
font-weight: bold; font-weight: bold;
} }
.form-field.file { .form-field.file label {
width: 100%; display: grid;
display: flex; grid-template-columns: auto 1fr;
}
.label {
grid-column: 1 / span 2;
}
}
span.form-info { span.form-info {
flex: 1 1 auto; flex: 1 1 auto;
@ -353,39 +365,70 @@ span.form-info {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
padding: 0.3rem 0; padding: 0.3rem 0;
font-weight: initial;
} }
.list { .list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
margin-top: 0.5rem;
max-height: 40rem; &.scrolling {
overflow: auto; 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 { .entry {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
background: $settings-entry-bg; background: $settings-entry-bg;
border: 0.1rem solid transparent;
&:nth-child(even) {
background: $settings-entry-alternate-bg;
}
&:hover { &:hover {
background: $settings-entry-hover-bg; background: $settings-entry-hover-bg;
} }
&:active, &:focus, &:hover {
border-color: $fg-accent;
}
}
}
.checkbox-list {
.header, .entry {
gap: 1rem;
} }
} }
.instance-list { .instance-list {
p {
margin-top: 0;
}
.filter { .filter {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
input {
width: auto;
flex: 1 1 auto;
}
} }
.entry { .entry {
padding: 0.3rem; padding: 0.5rem;
margin: 0.2rem 0; margin: 0.2rem 0;
#domain { #domain {
@ -406,9 +449,12 @@ span.form-info {
background: $settings-entry-bg; background: $settings-entry-bg;
.entry { .entry {
padding: 0.5rem;
flex-direction: column; flex-direction: column;
b {
padding-left: 0.4rem;
}
.emoji-group { .emoji-group {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -550,6 +596,7 @@ span.form-info {
.row { .row {
display: flex; display: flex;
gap: 0.5rem;
} }
.emoji-detail { .emoji-detail {
@ -566,6 +613,12 @@ span.form-info {
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
div {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
img { img {
height: 8.5rem; height: 8.5rem;
width: 8.5rem; width: 8.5rem;
@ -576,15 +629,13 @@ span.form-info {
} }
.update-category { .update-category {
margin-bottom: 1rem;
.combobox-wrapper button { .combobox-wrapper button {
font-size: 1rem; font-size: 1rem;
margin: 0.15rem 0; margin: 0.15rem 0;
} }
.row { .row {
margin-top: 0.4rem; margin-top: 0.1rem;
gap: 0.5rem;
} }
} }
@ -607,36 +658,18 @@ span.form-info {
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
& > span { span {
margin-bottom: -1rem; margin-bottom: -0.5rem;
} }
.action-buttons { .action-buttons {
gap: 1rem; gap: 1rem;
} }
.emoji-list { .checkbox-list {
display: flex; .entry {
flex-direction: column;
& > * {
gap: 1rem;
align-items: center;
padding: 0.5rem 1rem;
}
.header {
background: $gray2;
display: flex;
}
.row {
display: grid; display: grid;
grid-template-columns: auto auto 1fr; grid-template-columns: auto auto 1fr;
&:hover {
background: $settings-entry-hover-bg;
}
} }
.emoji { .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;
}
} }

View file

@ -19,88 +19,126 @@
"use strict"; "use strict";
const React = require("react"); 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 {
const user = require("../redux/reducers/user").actions; useTextInput,
const submit = require("../lib/submit"); useFileInput,
useBoolInput
} = require("../lib/form");
const FakeProfile = require("../components/fake-profile"); const useFormSubmit = require("../lib/form/submit");
const { formFields } = require("../components/form-fields");
const { const {
TextInput, TextInput,
TextArea, TextArea,
Checkbox, FileInput,
File Checkbox
} = formFields(user.setProfileVal, (state) => state.user.profile); } = 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() { module.exports = function UserProfile() {
const dispatch = Redux.useDispatch(); return (
const instance = Redux.useSelector(state => state.instances.current); <FormWithData
dataQuery={query.useVerifyCredentialsQuery}
const allowCustomCSS = instance.configuration.accounts.allow_custom_css; DataForm={UserProfileForm}
/>
const [errorMsg, setError] = React.useState("");
const [statusMsg, setStatus] = React.useState("");
const saveProfile = submit(
() => dispatch(api.user.updateProfile()),
{setStatus, setError}
); );
};
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 { data: instance } = query.useInstanceQuery();
const allowCustomCSS = React.useMemo(() => {
return instance?.configuration?.accounts?.allow_custom_css === true;
}, [instance]);
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 ( return (
<div className="user-profile"> <form className="user-profile" onSubmit={submitForm}>
<h1>Profile</h1> <h1>Profile</h1>
<div className="overview"> <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 className="files">
<div> <div>
<h3>Header</h3> <h3>Header</h3>
<File <FileInput
id="header" field={form.header}
fileType="image/*" accept="image/*"
/> />
</div> </div>
<div> <div>
<h3>Avatar</h3> <h3>Avatar</h3>
<File <FileInput
id="avatar" field={form.avatar}
fileType="image/*" accept="image/*"
/> />
</div> </div>
</div> </div>
</div> </div>
<TextInput <TextInput
id="display_name" field={form.displayName}
name="Name" label="Name"
placeHolder="A GoToSocial user" placeholder="A GoToSocial user"
/> />
<TextArea <TextArea
id="source.note" field={form.note}
name="Bio" label="Bio"
placeHolder="Just trying out GoToSocial, my pronouns are they/them and I like sloths." placeholder="Just trying out GoToSocial, my pronouns are they/them and I like sloths."
rows={8}
/> />
<Checkbox <Checkbox
id="locked" field={form.locked}
name="Manually approve follow requests" label="Manually approve follow requests"
/> />
<Checkbox <Checkbox
id="enable_rss" field={form.enableRSS}
name="Enable RSS feed of Public posts" label="Enable RSS feed of Public posts"
/> />
{ !allowCustomCSS ? null : {!allowCustomCSS ? null :
<TextArea <TextArea
id="custom_css" field={form.customCSS}
name="Custom CSS" label="Custom CSS"
className="monospace" 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> <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> </TextArea>
} }
<Submit onClick={saveProfile} label="Save profile info" errorMsg={errorMsg} statusMsg={statusMsg} /> <MutationButton label="Save profile info" result={result} />
</div> </form>
); );
}; }

View file

@ -18,42 +18,63 @@
"use strict"; "use strict";
const Promise = require("bluebird");
const React = require("react"); const React = require("react");
const Redux = require("react-redux");
const api = require("../lib/api"); const query = require("../lib/query");
const user = require("../redux/reducers/user").actions;
const submit = require("../lib/submit"); const {
useTextInput,
const Languages = require("../components/languages"); useBoolInput
const Submit = require("../components/submit"); } = require("../lib/form");
const useFormSubmit = require("../lib/form/submit");
const { const {
Checkbox,
Select, 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() { module.exports = function UserSettings() {
const dispatch = Redux.useDispatch(); return (
<FormWithData
const [errorMsg, setError] = React.useState(""); dataQuery={query.useVerifyCredentialsQuery}
const [statusMsg, setStatus] = React.useState(""); DataForm={UserSettingsForm}
/>
const updateSettings = submit(
() => dispatch(api.user.updateSettings()),
{setStatus, setError}
); );
};
function UserSettingsForm({ data }) {
const { source } = data;
/* form keys
- string source[privacy]
- bool source[sensitive]
- string source[language]
- string source[status_format]
*/
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 ( return (
<> <>
<div className="user-settings"> <form className="user-settings" onSubmit={submitForm}>
<h1>Post settings</h1> <h1>Post settings</h1>
<Select id="source.language" name="Default post language" options={ <Select field={form.language} label="Default post language" options={
<Languages/> <Languages />
}> }>
</Select> </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="private">Private / followers-only</option>
<option value="unlisted">Unlisted</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> <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>
<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="plain">Plain (default)</option>
<option value="markdown">Markdown</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> <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> </Select>
<Checkbox <Checkbox
id="source.sensitive" field={form.isSensitive}
name="Mark my posts as sensitive by default" label="Mark my posts as sensitive by default"
/> />
<Submit onClick={updateSettings} label="Save post settings" errorMsg={errorMsg} statusMsg={statusMsg}/> <MutationButton label="Save settings" result={result} />
</div> </form>
<div> <div>
<PasswordChange/> <PasswordChange />
</div> </div>
</> </>
); );
}; }
function PasswordChange() { function PasswordChange() {
const dispatch = Redux.useDispatch(); 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 [errorMsg, setError] = React.useState(""); const verifyNewPassword = useTextInput("verifyNewPassword", {
const [statusMsg, setStatus] = React.useState(""); validator(val) {
if (val != "" && val != form.newPassword.value) {
const [oldPassword, setOldPassword] = React.useState(""); return "Passwords do not match";
const [newPassword, setNewPassword] = React.useState(""); }
const [newPasswordConfirm, setNewPasswordConfirm] = React.useState(""); return "";
function changePassword() {
if (newPassword !== newPasswordConfirm) {
setError("New password and confirm new password did not match!");
return;
} }
});
setStatus("PATCHing"); const [submitForm, result] = useFormSubmit(form, query.usePasswordChangeMutation());
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("");
});
}
return ( return (
<> <form className="change-password" onSubmit={submitForm}>
<h1>Change password</h1> <h1>Change password</h1>
<div className="labelinput"> <TextInput type="password" field={form.oldPassword} label="Current password" />
<label htmlFor="password">Current password</label> <TextInput type="password" field={form.newPassword} label="New password" />
<input name="password" id="password" type="password" autoComplete="current-password" value={oldPassword} onChange={(e) => setOldPassword(e.target.value)} /> <TextInput type="password" field={verifyNewPassword} label="Confirm new password" />
</div> <MutationButton label="Change password" result={result} />
<div className="labelinput"> </form>
<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}/>
</>
); );
} }

View file

@ -2463,11 +2463,6 @@ domain-browser@^1.2.0:
resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda" resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda"
integrity sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA== 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: duplexer2@^0.1.2, duplexer2@~0.1.0, duplexer2@~0.1.2, duplexer2@~0.1.4:
version "0.1.4" version "0.1.4"
resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1" resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1"
@ -2653,6 +2648,13 @@ escodegen@^1.11.1:
optionalDependencies: optionalDependencies:
source-map "~0.6.1" 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: eslint-plugin-react-hooks@^4.6.0:
version "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" 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" resolved "https://registry.yarnpkg.com/remove-accents/-/remove-accents-0.4.2.tgz#0a43d3aaae1e80db919e07ae254b285d9e1c7bb5"
integrity sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA== 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: requires-port@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff"