forked from mirrors/gotosocial
[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:
parent
974ec80a20
commit
9b139b6320
69 changed files with 3129 additions and 2663 deletions
|
@ -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"]
|
||||||
|
}
|
||||||
};
|
};
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
|
@ -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;
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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>< go back</a></Link>
|
<Link to={base}><a>< 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't it cool?
|
/> isn'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>
|
|
||||||
);
|
|
||||||
}
|
}
|
|
@ -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>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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't it cool?
|
Look at this new custom emoji {emojiOrShortcode} isn'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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
61
web/source/settings/admin/emoji/local/use-shortcode.js
Normal file
61
web/source/settings/admin/emoji/local/use-shortcode.js
Normal 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 "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
|
@ -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} />
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -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>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
146
web/source/settings/admin/federation/detail.js
Normal file
146
web/source/settings/admin/federation/detail.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
307
web/source/settings/admin/federation/import-export.js
Normal file
307
web/source/settings/admin/federation/import-export.js
Normal 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);
|
||||||
|
}}>
|
||||||
|
< 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
100
web/source/settings/admin/federation/overview.js
Normal file
100
web/source/settings/admin/federation/overview.js
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
}
|
76
web/source/settings/components/authorization/index.jsx
Normal file
76
web/source/settings/components/authorization/index.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
67
web/source/settings/components/authorization/login.jsx
Normal file
67
web/source/settings/components/authorization/login.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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">< back</a>
|
<a className="button">< back</a>
|
||||||
|
|
58
web/source/settings/components/check-list.jsx
Normal file
58
web/source/settings/components/check-list.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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}
|
||||||
|
|
|
@ -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 };
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
|
||||||
};
|
|
|
@ -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"))
|
|
||||||
};
|
|
141
web/source/settings/components/form/inputs.jsx
Normal file
141
web/source/settings/components/form/inputs.jsx
Normal 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
|
||||||
|
};
|
49
web/source/settings/components/form/mutation-button.jsx
Normal file
49
web/source/settings/components/form/mutation-button.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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" />
|
||||||
);
|
);
|
||||||
};
|
};
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -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}>
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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;
|
|
||||||
};
|
|
|
@ -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
|
|
||||||
};
|
|
|
@ -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());
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
|
@ -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});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
|
@ -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"),
|
|
||||||
};
|
|
50
web/source/settings/lib/form/bool.jsx
Normal file
50
web/source/settings/lib/form/bool.jsx
Normal 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
|
||||||
|
});
|
||||||
|
};
|
147
web/source/settings/lib/form/check-list.jsx
Normal file
147
web/source/settings/lib/form/check-list.jsx
Normal 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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
|
@ -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
|
||||||
|
});
|
||||||
};
|
};
|
|
@ -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
|
||||||
|
});
|
||||||
};
|
};
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
};
|
|
@ -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
|
||||||
|
};
|
||||||
}
|
}
|
||||||
});
|
};
|
51
web/source/settings/lib/form/radio.jsx
Normal file
51
web/source/settings/lib/form/radio.jsx
Normal 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
|
||||||
|
});
|
||||||
|
};
|
83
web/source/settings/lib/form/submit.js
Normal file
83
web/source/settings/lib/form/submit.js
Normal 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
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
|
@ -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
|
||||||
|
});
|
||||||
};
|
};
|
|
@ -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 />
|
||||||
|
|
195
web/source/settings/lib/query/admin/custom-emoji.js
Normal file
195
web/source/settings/lib/query/admin/custom-emoji.js
Normal 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
|
||||||
|
};
|
||||||
|
}
|
212
web/source/settings/lib/query/admin/import-export.js
Normal file
212
web/source/settings/lib/query/admin/import-export.js
Normal 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];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
84
web/source/settings/lib/query/admin/index.js
Normal file
84
web/source/settings/lib/query/admin/index.js
Normal 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 });
|
|
@ -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`
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
});
|
});
|
|
@ -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});
|
|
|
@ -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")
|
||||||
};
|
};
|
75
web/source/settings/lib/query/lib.js
Normal file
75
web/source/settings/lib/query/lib.js
Normal 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 });
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
158
web/source/settings/lib/query/oauth.js
Normal file
158
web/source/settings/lib/query/oauth.js
Normal 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 });
|
|
@ -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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
};
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
|
@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
}
|
|
@ -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}/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
|
@ -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"
|
||||||
|
|
Loading…
Reference in a new issue