mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2024-11-25 21:26:40 +00:00
[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
|
@ -1,5 +1,27 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
"extends": ["@joepie91/eslint-config/react"]
|
||||
"extends": ["@joepie91/eslint-config/react"],
|
||||
"plugins": ["license-header"],
|
||||
"rules": {
|
||||
"license-header/header": ["error", ".license-header.js"]
|
||||
}
|
||||
};
|
|
@ -15,18 +15,3 @@
|
|||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
const {createSlice} = require("@reduxjs/toolkit");
|
||||
|
||||
module.exports = createSlice({
|
||||
name: "temporary",
|
||||
initialState: {
|
||||
},
|
||||
reducers: {
|
||||
setStatus: function(state, {payload}) {
|
||||
state.status = payload;
|
||||
}
|
||||
}
|
||||
});
|
|
@ -47,7 +47,13 @@ $blue3: #89caff; /* hover/selected accent to $blue2, can be used with $gray1 (7.
|
|||
$error1: #860000; /* Error border/foreground text, can be used with $error2 (5.0), $white1 (10), $white2 (5.1) */
|
||||
$error2: #ff9796; /* Error background text, can be used with $error1 (5.0), $gray1 (6.6), $gray2 (5.3), $gray3 (4.8) */
|
||||
$error3: #dd2c2c; /* Error button background text, can be used with $white1 (4.51) */
|
||||
$error-link: #185F8C; /* Error link text, can be used with $error2 (5.54) */
|
||||
$error-link: #01318C; /* Error link text, can be used with $error2 (5.56) */
|
||||
|
||||
$green1: #94E749; /* Green for positive/confirmation, similar contrast (luminance) as $blue2 */
|
||||
|
||||
$info-fg: $gray1;
|
||||
$info-bg: #b3ddff;
|
||||
$info-link: $error-link;
|
||||
|
||||
$fg: $white1;
|
||||
$bg: $gray1;
|
||||
|
@ -92,6 +98,7 @@ $avatar-border: $orange2;
|
|||
$input-bg: $gray4;
|
||||
$input-disabled-bg: $gray2;
|
||||
$input-border: $blue1;
|
||||
$input-error-border: $error3;
|
||||
$input-focus-border: $blue3;
|
||||
|
||||
$settings-nav-bg: $bg-accent;
|
||||
|
@ -107,5 +114,6 @@ $settings-nav-bg-active: $gray2;
|
|||
$error-fg: $error1;
|
||||
$error-bg: $error2;
|
||||
|
||||
$settings-entry-bg: $gray3;
|
||||
$settings-entry-bg: $gray2;
|
||||
$settings-entry-alternate-bg: $gray3;
|
||||
$settings-entry-hover-bg: $gray4;
|
|
@ -311,12 +311,16 @@ input, select, textarea, .input {
|
|||
font-size: 1rem;
|
||||
padding: 0.3rem;
|
||||
|
||||
&:focus {
|
||||
&:focus, &:active {
|
||||
border-color: $input-focus-border;
|
||||
}
|
||||
|
||||
&:invalid {
|
||||
border-color: $input-error-border;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: $input-disabled-bg;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -66,6 +66,7 @@ skulk({
|
|||
],
|
||||
},
|
||||
settings: {
|
||||
debug: false,
|
||||
entryFile: "settings",
|
||||
outputFile: "settings.js",
|
||||
prodCfg: prodCfg,
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
"author": "f0x",
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
"lint": "eslint .",
|
||||
"lint": "eslint . --ext .js,.jsx",
|
||||
"build": "node index.js",
|
||||
"dev": "NODE_ENV=development node index.js"
|
||||
},
|
||||
|
@ -14,7 +14,6 @@
|
|||
"@reduxjs/toolkit": "^1.8.6",
|
||||
"ariakit": "^2.0.0-next.41",
|
||||
"bluebird": "^3.7.2",
|
||||
"dotty": "^0.1.2",
|
||||
"is-valid-domain": "^0.1.6",
|
||||
"js-file-download": "^0.4.12",
|
||||
"langs": "^2.0.0",
|
||||
|
@ -44,6 +43,7 @@
|
|||
"babelify": "^10.0.0",
|
||||
"css-extract": "^2.0.0",
|
||||
"eslint": "^8.26.0",
|
||||
"eslint-plugin-license-header": "^0.6.0",
|
||||
"eslint-plugin-react": "^7.31.10",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"factor-bundle": "^2.5.0",
|
||||
|
|
|
@ -19,42 +19,43 @@
|
|||
"use strict";
|
||||
|
||||
const React = require("react");
|
||||
const Redux = require("react-redux");
|
||||
|
||||
const Submit = require("../components/submit");
|
||||
const query = require("../lib/query");
|
||||
|
||||
const api = require("../lib/api");
|
||||
const submit = require("../lib/submit");
|
||||
const { useTextInput } = require("../lib/form");
|
||||
const { TextInput } = require("../components/form/inputs");
|
||||
|
||||
const MutationButton = require("../components/form/mutation-button");
|
||||
|
||||
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("");
|
||||
const [statusMsg, setStatus] = React.useState("");
|
||||
|
||||
const removeMedia = submit(
|
||||
() => dispatch(api.admin.mediaCleanup(days)),
|
||||
{setStatus, setError}
|
||||
);
|
||||
function submitMediaCleanup(e) {
|
||||
e.preventDefault();
|
||||
mediaCleanup(daysField.value);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Admin Actions</h1>
|
||||
<div>
|
||||
<form onSubmit={submitMediaCleanup}>
|
||||
<h2>Media cleanup</h2>
|
||||
<p>
|
||||
Clean up remote media older than the specified number of days.
|
||||
If the remote instance is still online they will be refetched when needed.
|
||||
Also cleans up unused headers and avatars from the media cache.
|
||||
</p>
|
||||
<div>
|
||||
<label htmlFor="days">Days: </label>
|
||||
<input id="days" type="number" value={days} onChange={(e) => setDays(e.target.value)}/>
|
||||
</div>
|
||||
<Submit onClick={removeMedia} label="Remove media" errorMsg={errorMsg} statusMsg={statusMsg} />
|
||||
</div>
|
||||
<TextInput
|
||||
field={daysField}
|
||||
label="Days"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="30"
|
||||
/>
|
||||
<MutationButton label="Remove old media" result={mediaCleanupResult} />
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -36,7 +36,9 @@ function useEmojiByCategory(emoji) {
|
|||
), [emoji]);
|
||||
}
|
||||
|
||||
function CategorySelect({value, categoryState, setIsNew=() => {}, children}) {
|
||||
function CategorySelect({ field, children }) {
|
||||
const { value, setIsNew } = field;
|
||||
|
||||
const {
|
||||
data: emoji = [],
|
||||
isLoading,
|
||||
|
@ -67,12 +69,12 @@ function CategorySelect({value, categoryState, setIsNew=() => {}, children}) {
|
|||
if (value != undefined && isSuccess && value.trim().length > 0) {
|
||||
setIsNew(!categories.has(value.trim()));
|
||||
}
|
||||
}, [categories, value, setIsNew, isSuccess]);
|
||||
}, [categories, value, isSuccess, setIsNew]);
|
||||
|
||||
if (error) { // fall back to plain text input, but this would almost certainly have caused a bigger error message elsewhere
|
||||
return (
|
||||
<>
|
||||
<input type="text" placeholder="e.g., reactions" onChange={(e) => {categoryState.value = e.target.value;}}/>;
|
||||
<input type="text" placeholder="e.g., reactions" onChange={(e) => { field.value = e.target.value; }} />;
|
||||
</>
|
||||
);
|
||||
} else if (isLoading) {
|
||||
|
@ -81,10 +83,10 @@ function CategorySelect({value, categoryState, setIsNew=() => {}, children}) {
|
|||
|
||||
return (
|
||||
<ComboBox
|
||||
state={categoryState}
|
||||
field={field}
|
||||
items={categoryItems}
|
||||
label="Category"
|
||||
placeHolder="e.g., reactions"
|
||||
placeholder="e.g., reactions"
|
||||
children={children}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -19,15 +19,21 @@
|
|||
"use strict";
|
||||
|
||||
const React = require("react");
|
||||
|
||||
const { useRoute, Link, Redirect } = require("wouter");
|
||||
|
||||
const { CategorySelect } = require("../category-select");
|
||||
const { useComboBoxInput, useFileInput } = require("../../../components/form");
|
||||
|
||||
const query = require("../../../lib/query");
|
||||
|
||||
const { useComboBoxInput, useFileInput, useValue } = require("../../../lib/form");
|
||||
const { CategorySelect } = require("../category-select");
|
||||
|
||||
const useFormSubmit = require("../../../lib/form/submit");
|
||||
|
||||
const FakeToot = require("../../../components/fake-toot");
|
||||
const FormWithData = require("../../../lib/form/form-with-data");
|
||||
const Loading = require("../../../components/loading");
|
||||
const { FileInput } = require("../../../components/form/inputs");
|
||||
const MutationButton = require("../../../components/form/mutation-button");
|
||||
const { Error } = require("../../../components/error");
|
||||
|
||||
const base = "/settings/custom-emoji/local";
|
||||
|
||||
|
@ -39,58 +45,40 @@ module.exports = function EmojiDetailRoute() {
|
|||
return (
|
||||
<div className="emoji-detail">
|
||||
<Link to={base}><a>< go back</a></Link>
|
||||
<EmojiDetailData emojiId={params.emojiId}/>
|
||||
<FormWithData dataQuery={query.useGetEmojiQuery} queryArg={params.emojiId} DataForm={EmojiDetailForm} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
function EmojiDetailData({emojiId}) {
|
||||
const {currentData: emoji, isLoading, error} = query.useGetEmojiQuery(emojiId);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="error accent">
|
||||
{error.status}: {error.data.error}
|
||||
</div>
|
||||
);
|
||||
} else if (isLoading) {
|
||||
return (
|
||||
<div>
|
||||
<Loading/>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return <EmojiDetail emoji={emoji}/>;
|
||||
}
|
||||
}
|
||||
|
||||
function EmojiDetail({emoji}) {
|
||||
const [modifyEmoji, modifyResult] = query.useEditEmojiMutation();
|
||||
|
||||
const [isNewCategory, setIsNewCategory] = React.useState(false);
|
||||
|
||||
const [categoryState, _resetCategory, { category }] = useComboBoxInput("category", {defaultValue: emoji.category});
|
||||
|
||||
const [onFileChange, _resetFile, { image, imageURL, imageInfo }] = useFileInput("image", {
|
||||
function EmojiDetailForm({ data: emoji }) {
|
||||
const form = {
|
||||
id: useValue("id", emoji.id),
|
||||
category: useComboBoxInput("category", { defaultValue: emoji.category }),
|
||||
image: useFileInput("image", {
|
||||
withPreview: true,
|
||||
maxSize: 50 * 1024
|
||||
});
|
||||
maxSize: 50 * 1024 // TODO: get from instance api
|
||||
})
|
||||
};
|
||||
|
||||
function modifyCategory() {
|
||||
modifyEmoji({id: emoji.id, category: category.trim()});
|
||||
}
|
||||
|
||||
function modifyImage() {
|
||||
modifyEmoji({id: emoji.id, image: image});
|
||||
}
|
||||
const [modifyEmoji, result] = useFormSubmit(form, query.useEditEmojiMutation());
|
||||
|
||||
// Automatic submitting of category change
|
||||
React.useEffect(() => {
|
||||
if (category != emoji.category && !categoryState.open && !isNewCategory && category.trim().length > 0) {
|
||||
console.log("updating to", category);
|
||||
modifyEmoji({id: emoji.id, category: category.trim()});
|
||||
if (
|
||||
form.category.hasChanged() &&
|
||||
!form.category.state.open &&
|
||||
!form.category.isNew) {
|
||||
modifyEmoji();
|
||||
}
|
||||
/* eslint-disable-next-line react-hooks/exhaustive-deps */
|
||||
}, [form.category.hasChanged(), form.category.isNew, form.category.state.open]);
|
||||
|
||||
const [deleteEmoji, deleteResult] = query.useDeleteEmojiMutation();
|
||||
|
||||
if (deleteResult.isSuccess) {
|
||||
return <Redirect to={base} />;
|
||||
}
|
||||
}, [isNewCategory, category, categoryState.open, emoji.category, emoji.id, modifyEmoji]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -98,76 +86,61 @@ function EmojiDetail({emoji}) {
|
|||
<img src={emoji.url} alt={emoji.shortcode} title={emoji.shortcode} />
|
||||
<div>
|
||||
<h2>{emoji.shortcode}</h2>
|
||||
<DeleteButton id={emoji.id}/>
|
||||
<MutationButton
|
||||
label="Delete"
|
||||
type="button"
|
||||
onClick={() => deleteEmoji(emoji.id)}
|
||||
className="danger"
|
||||
showError={false}
|
||||
result={deleteResult}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="left-border">
|
||||
<h2>Modify this emoji {modifyResult.isLoading && "(processing..)"}</h2>
|
||||
|
||||
{modifyResult.error && <div className="error">
|
||||
{modifyResult.error.status}: {modifyResult.error.data.error}
|
||||
</div>}
|
||||
<form onSubmit={modifyEmoji} className="left-border">
|
||||
<h2>Modify this emoji {result.isLoading && <Loading />}</h2>
|
||||
|
||||
<div className="update-category">
|
||||
<CategorySelect
|
||||
value={category}
|
||||
categoryState={categoryState}
|
||||
setIsNew={setIsNewCategory}
|
||||
field={form.category}
|
||||
>
|
||||
<button style={{visibility: (isNewCategory ? "initial" : "hidden")}} onClick={modifyCategory}>
|
||||
Create
|
||||
</button>
|
||||
<MutationButton
|
||||
name="create-category"
|
||||
label="Create"
|
||||
result={result}
|
||||
showError={false}
|
||||
style={{ visibility: (form.category.isNew ? "initial" : "hidden") }}
|
||||
/>
|
||||
</CategorySelect>
|
||||
</div>
|
||||
|
||||
<div className="update-image">
|
||||
<b>Image</b>
|
||||
<div className="form-field file">
|
||||
<label className="file-input button" htmlFor="image">
|
||||
Browse
|
||||
</label>
|
||||
{imageInfo}
|
||||
<input
|
||||
className="hidden"
|
||||
type="file"
|
||||
id="image"
|
||||
name="Image"
|
||||
<FileInput
|
||||
field={form.image}
|
||||
label="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>
|
||||
Look at this new custom emoji <img
|
||||
className="emoji"
|
||||
src={imageURL ?? emoji.url}
|
||||
src={form.image.previewURL ?? emoji.url}
|
||||
title={`:${emoji.shortcode}:`}
|
||||
alt={emoji.shortcode}
|
||||
/> isn't it cool?
|
||||
</FakeToot>
|
||||
|
||||
{result.error && <Error error={result.error} />}
|
||||
{deleteResult.error && <Error error={deleteResult.error} />}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteButton({id}) {
|
||||
// TODO: confirmation dialog?
|
||||
const [deleteEmoji, deleteResult] = query.useDeleteEmojiMutation();
|
||||
|
||||
let text = "Delete";
|
||||
if (deleteResult.isLoading) {
|
||||
text = "Deleting...";
|
||||
}
|
||||
|
||||
if (deleteResult.isSuccess) {
|
||||
return <Redirect to={base}/>;
|
||||
}
|
||||
|
||||
return (
|
||||
<button className="danger" onClick={() => deleteEmoji(id)} disabled={deleteResult.isLoading}>{text}</button>
|
||||
);
|
||||
}
|
|
@ -28,13 +28,11 @@ const base = "/settings/custom-emoji/local";
|
|||
|
||||
module.exports = function CustomEmoji() {
|
||||
return (
|
||||
<>
|
||||
<Switch>
|
||||
<Route path={`${base}/:emojiId`}>
|
||||
<EmojiDetail />
|
||||
<EmojiDetail baseUrl={base} />
|
||||
</Route>
|
||||
<EmojiOverview />
|
||||
<EmojiOverview baseUrl={base} />
|
||||
</Switch>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -18,101 +18,61 @@
|
|||
|
||||
"use strict";
|
||||
|
||||
const Promise = require('bluebird');
|
||||
const React = require("react");
|
||||
|
||||
const FakeToot = require("../../../components/fake-toot");
|
||||
const MutateButton = require("../../../components/mutation-button");
|
||||
const query = require("../../../lib/query");
|
||||
|
||||
const {
|
||||
useTextInput,
|
||||
useFileInput,
|
||||
useComboBoxInput
|
||||
} = require("../../../components/form");
|
||||
} = require("../../../lib/form");
|
||||
const useShortcode = require("./use-shortcode");
|
||||
|
||||
const useFormSubmit = require("../../../lib/form/submit");
|
||||
|
||||
const {
|
||||
TextInput, FileInput
|
||||
} = require("../../../components/form/inputs");
|
||||
|
||||
const query = require("../../../lib/query");
|
||||
const { CategorySelect } = require('../category-select');
|
||||
const FakeToot = require("../../../components/fake-toot");
|
||||
const MutationButton = require("../../../components/form/mutation-button");
|
||||
|
||||
const shortcodeRegex = /^[a-z0-9_]+$/;
|
||||
module.exports = function NewEmojiForm() {
|
||||
const shortcode = useShortcode();
|
||||
|
||||
module.exports = function NewEmojiForm({ emoji }) {
|
||||
const emojiCodes = React.useMemo(() => {
|
||||
return new Set(emoji.map((e) => e.shortcode));
|
||||
}, [emoji]);
|
||||
|
||||
const [addEmoji, result] = query.useAddEmojiMutation();
|
||||
|
||||
const [onFileChange, resetFile, { image, imageURL, imageInfo }] = useFileInput("image", {
|
||||
const image = useFileInput("image", {
|
||||
withPreview: true,
|
||||
maxSize: 50 * 1024
|
||||
maxSize: 50 * 1024 // TODO: get from instance api?
|
||||
});
|
||||
|
||||
const [onShortcodeChange, resetShortcode, { shortcode, setShortcode, shortcodeRef }] = useTextInput("shortcode", {
|
||||
validator: function validateShortcode(code) {
|
||||
// technically invalid, but hacky fix to prevent validation error on page load
|
||||
if (shortcode == "") {return "";}
|
||||
const category = useComboBoxInput("category");
|
||||
|
||||
if (emojiCodes.has(code)) {
|
||||
return "Shortcode already in use";
|
||||
}
|
||||
|
||||
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");
|
||||
const [submitForm, result] = useFormSubmit({
|
||||
shortcode, image, category
|
||||
}, query.useAddEmojiMutation());
|
||||
|
||||
React.useEffect(() => {
|
||||
if (shortcode.length == 0) {
|
||||
if (image != undefined) {
|
||||
let [name, _ext] = image.name.split(".");
|
||||
setShortcode(name);
|
||||
if (shortcode.value.length == 0) {
|
||||
if (image.value != undefined) {
|
||||
let [name, _ext] = image.value.name.split(".");
|
||||
shortcode.setter(name);
|
||||
}
|
||||
}
|
||||
// we explicitly don't want to add 'shortcode' as a dependency here
|
||||
// because we only want this to update to the filename if the field is empty
|
||||
// at the moment the file is selected, not some time after when the field is emptied
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [image]);
|
||||
|
||||
function uploadEmoji(e) {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
/* We explicitly don't want to have 'shortcode' as a dependency here
|
||||
because we only want to change the shortcode to the filename if the field is empty
|
||||
at the moment the file is selected, not some time after when the field is emptied
|
||||
*/
|
||||
/* eslint-disable-next-line react-hooks/exhaustive-deps */
|
||||
}, [image.value]);
|
||||
|
||||
Promise.try(() => {
|
||||
return addEmoji({
|
||||
image,
|
||||
shortcode,
|
||||
category
|
||||
}).unwrap();
|
||||
}).then(() => {
|
||||
resetFile();
|
||||
resetShortcode();
|
||||
resetCategory();
|
||||
}).catch((e) => {
|
||||
console.error("Emoji upload error:", e);
|
||||
});
|
||||
}
|
||||
let emojiOrShortcode = `:${shortcode.value}:`;
|
||||
|
||||
let emojiOrShortcode = `:${shortcode}:`;
|
||||
|
||||
if (imageURL != undefined) {
|
||||
if (image.previewValue != undefined) {
|
||||
emojiOrShortcode = <img
|
||||
className="emoji"
|
||||
src={imageURL}
|
||||
src={image.previewValue}
|
||||
title={`:${shortcode}:`}
|
||||
alt={shortcode}
|
||||
/>;
|
||||
|
@ -126,42 +86,22 @@ module.exports = function NewEmojiForm({ emoji }) {
|
|||
Look at this new custom emoji {emojiOrShortcode} isn't it cool?
|
||||
</FakeToot>
|
||||
|
||||
<form onSubmit={uploadEmoji} className="form-flex">
|
||||
<div className="form-field file">
|
||||
<label className="file-input button" htmlFor="image">
|
||||
Browse
|
||||
</label>
|
||||
{imageInfo}
|
||||
<input
|
||||
className="hidden"
|
||||
type="file"
|
||||
id="image"
|
||||
name="Image"
|
||||
<form onSubmit={submitForm} className="form-flex">
|
||||
<FileInput
|
||||
field={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}
|
||||
<TextInput
|
||||
field={shortcode}
|
||||
label="Shortcode, must be unique among the instance's local emoji"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CategorySelect
|
||||
value={category}
|
||||
categoryState={categoryState}
|
||||
field={category}
|
||||
/>
|
||||
|
||||
<MutateButton text="Upload emoji" result={result} />
|
||||
<MutationButton label="Upload emoji" result={result} />
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -27,9 +27,7 @@ const query = require("../../../lib/query");
|
|||
const { useEmojiByCategory } = require("../category-select");
|
||||
const Loading = require("../../../components/loading");
|
||||
|
||||
const base = "/settings/custom-emoji/local";
|
||||
|
||||
module.exports = function EmojiOverview() {
|
||||
module.exports = function EmojiOverview({ baseUrl }) {
|
||||
const {
|
||||
data: emoji = [],
|
||||
isLoading,
|
||||
|
@ -45,7 +43,7 @@ module.exports = function EmojiOverview() {
|
|||
{isLoading
|
||||
? <Loading />
|
||||
: <>
|
||||
<EmojiList emoji={emoji}/>
|
||||
<EmojiList emoji={emoji} baseUrl={baseUrl} />
|
||||
<NewEmojiForm emoji={emoji} />
|
||||
</>
|
||||
}
|
||||
|
@ -53,7 +51,7 @@ module.exports = function EmojiOverview() {
|
|||
);
|
||||
};
|
||||
|
||||
function EmojiList({emoji}) {
|
||||
function EmojiList({ emoji, baseUrl }) {
|
||||
const emojiByCategory = useEmojiByCategory(emoji);
|
||||
|
||||
return (
|
||||
|
@ -62,22 +60,21 @@ function EmojiList({emoji}) {
|
|||
<div className="list emoji-list">
|
||||
{emoji.length == 0 && "No local emoji yet, add one below"}
|
||||
{Object.entries(emojiByCategory).map(([category, entries]) => {
|
||||
return <EmojiCategory key={category} category={category} entries={entries}/>;
|
||||
return <EmojiCategory key={category} category={category} entries={entries} baseUrl={baseUrl} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmojiCategory({category, entries}) {
|
||||
function EmojiCategory({ category, entries, baseUrl }) {
|
||||
return (
|
||||
<div className="entry">
|
||||
<b>{category}</b>
|
||||
<div className="emoji-group">
|
||||
{entries.map((e) => {
|
||||
return (
|
||||
<Link key={e.id} to={`${base}/${e.id}`}>
|
||||
{/* <Link key={e.static_url} to={`${base}`}> */}
|
||||
<Link key={e.id} to={`${baseUrl}/${e.id}`}>
|
||||
<a>
|
||||
<img src={e.url} alt={e.shortcode} title={`:${e.shortcode}:`} />
|
||||
</a>
|
||||
|
|
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 "";
|
||||
}
|
||||
});
|
||||
};
|
|
@ -18,58 +18,36 @@
|
|||
|
||||
"use strict";
|
||||
|
||||
const Promise = require("bluebird");
|
||||
const React = require("react");
|
||||
const Redux = require("react-redux");
|
||||
const syncpipe = require("syncpipe");
|
||||
|
||||
const query = require("../../../lib/query");
|
||||
|
||||
const {
|
||||
useTextInput,
|
||||
useComboBoxInput
|
||||
} = require("../../../components/form");
|
||||
useComboBoxInput,
|
||||
useCheckListInput
|
||||
} = require("../../../lib/form");
|
||||
|
||||
const useFormSubmit = require("../../../lib/form/submit");
|
||||
|
||||
const CheckList = require("../../../components/check-list");
|
||||
const { CategorySelect } = require('../category-select');
|
||||
|
||||
const query = require("../../../lib/query");
|
||||
const Loading = require("../../../components/loading");
|
||||
const { TextInput } = require("../../../components/form/inputs");
|
||||
const MutationButton = require("../../../components/form/mutation-button");
|
||||
const { Error } = require("../../../components/error");
|
||||
|
||||
module.exports = function ParseFromToot({ emojiCodes }) {
|
||||
const [searchStatus, { data, isLoading, isSuccess, error }] = query.useSearchStatusForEmojiMutation();
|
||||
const instanceDomain = Redux.useSelector((state) => (new URL(state.oauth.instance).host));
|
||||
const [searchStatus, result] = query.useSearchStatusForEmojiMutation();
|
||||
|
||||
const [onURLChange, _resetURL, { url }] = useTextInput("url");
|
||||
|
||||
const searchResult = React.useMemo(() => {
|
||||
if (!isSuccess) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (data.type == "none") {
|
||||
return "No results found";
|
||||
}
|
||||
|
||||
if (data.domain == instanceDomain) {
|
||||
return <b>This is a local user/toot, all referenced emoji are already on your instance</b>;
|
||||
}
|
||||
|
||||
if (data.list.length == 0) {
|
||||
return <b>This {data.type == "statuses" ? "toot" : "account"} doesn't use any custom emoji</b>;
|
||||
}
|
||||
|
||||
return (
|
||||
<CopyEmojiForm
|
||||
localEmojiCodes={emojiCodes}
|
||||
type={data.type}
|
||||
domain={data.domain}
|
||||
emojiList={data.list}
|
||||
/>
|
||||
);
|
||||
}, [isSuccess, data, instanceDomain, emojiCodes]);
|
||||
|
||||
function submitSearch(e) {
|
||||
e.preventDefault();
|
||||
if (url.trim().length != 0) {
|
||||
searchStatus(url);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="parse-emoji">
|
||||
|
@ -87,233 +65,137 @@ module.exports = function ParseFromToot({ emojiCodes }) {
|
|||
onChange={onURLChange}
|
||||
value={url}
|
||||
/>
|
||||
<button disabled={isLoading}>
|
||||
<button disabled={result.isLoading}>
|
||||
<i className={[
|
||||
"fa",
|
||||
(isLoading
|
||||
"fa fa-fw",
|
||||
(result.isLoading
|
||||
? "fa-refresh fa-spin"
|
||||
: "fa-search")
|
||||
].join(" ")} aria-hidden="true" title="Search" />
|
||||
<span className="sr-only">Search</span>
|
||||
</button>
|
||||
</div>
|
||||
{isLoading && <Loading/>}
|
||||
{error && <div className="error">{error.data.error}</div>}
|
||||
</div>
|
||||
</form>
|
||||
{searchResult}
|
||||
<SearchResult result={result} localEmojiCodes={emojiCodes} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function makeEmojiState(emojiList, checked) {
|
||||
/* Return a new object, with a key for every emoji's shortcode,
|
||||
And a value for it's checkbox `checked` state.
|
||||
*/
|
||||
return syncpipe(emojiList, [
|
||||
(_) => _.map((emoji) => [emoji.shortcode, {
|
||||
checked,
|
||||
valid: true
|
||||
}]),
|
||||
(_) => Object.fromEntries(_)
|
||||
]);
|
||||
function SearchResult({ result, localEmojiCodes }) {
|
||||
const { error, data, isSuccess, isError } = result;
|
||||
|
||||
if (!(isSuccess || isError)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function updateEmojiState(emojiState, checked) {
|
||||
/* Create a new object with all emoji entries' checked state updated */
|
||||
return syncpipe(emojiState, [
|
||||
(_) => Object.entries(emojiState),
|
||||
(_) => _.map(([key, val]) => [key, {
|
||||
...val,
|
||||
checked
|
||||
}]),
|
||||
(_) => Object.fromEntries(_)
|
||||
]);
|
||||
if (error == "NONE_FOUND") {
|
||||
return "No results found";
|
||||
} else if (error == "LOCAL_INSTANCE") {
|
||||
return <b>This is a local user/toot, all referenced emoji are already on your instance</b>;
|
||||
} else if (error != undefined) {
|
||||
return <Error error={result.error} />;
|
||||
}
|
||||
|
||||
function CopyEmojiForm({ localEmojiCodes, type, domain, emojiList }) {
|
||||
const [patchRemoteEmojis, patchResult] = query.usePatchRemoteEmojisMutation();
|
||||
const [err, setError] = React.useState();
|
||||
|
||||
const toggleAllRef = React.useRef(null);
|
||||
const [toggleAllState, setToggleAllState] = React.useState(0);
|
||||
const [emojiState, setEmojiState] = React.useState(makeEmojiState(emojiList, false));
|
||||
const [someSelected, setSomeSelected] = React.useState(false);
|
||||
|
||||
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;
|
||||
if (data.list.length == 0) {
|
||||
return <b>This {data.type == "statuses" ? "toot" : "account"} doesn't use any custom emoji</b>;
|
||||
}
|
||||
|
||||
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);
|
||||
return (
|
||||
<CopyEmojiForm
|
||||
localEmojiCodes={localEmojiCodes}
|
||||
type={data.type}
|
||||
domain={data.domain}
|
||||
emojiList={data.list}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
function CopyEmojiForm({ localEmojiCodes, type, emojiList }) {
|
||||
const form = {
|
||||
selectedEmoji: useCheckListInput("selectedEmoji", {
|
||||
entries: emojiList,
|
||||
uniqueKey: "shortcode"
|
||||
}),
|
||||
category: useComboBoxInput("category")
|
||||
};
|
||||
})
|
||||
]);
|
||||
|
||||
return patchRemoteEmojis({
|
||||
action,
|
||||
domain,
|
||||
list: selectedShortcodes,
|
||||
category
|
||||
}).unwrap();
|
||||
}).then(() => {
|
||||
setEmojiState(makeEmojiState(emojiList, false));
|
||||
resetCategory();
|
||||
}).catch((e) => {
|
||||
if (Array.isArray(e)) {
|
||||
setError(e.map(([shortcode, msg]) => (
|
||||
<div key={shortcode}>
|
||||
{shortcode}: <span style={{ fontWeight: "initial" }}>{msg}</span>
|
||||
</div>
|
||||
)));
|
||||
} else {
|
||||
setError(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
const [formSubmit, result] = useFormSubmit(form, query.usePatchRemoteEmojisMutation(), { changedOnly: false });
|
||||
|
||||
const buttonsInactive = form.selectedEmoji.someSelected
|
||||
? {}
|
||||
: {
|
||||
disabled: true,
|
||||
title: "No emoji selected, cannot perform any actions"
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="parsed">
|
||||
<span>This {type == "statuses" ? "toot" : "account"} uses the following custom emoji, select the ones you want to copy/disable:</span>
|
||||
<div className="emoji-list">
|
||||
<label className="header">
|
||||
<input
|
||||
ref={toggleAllRef}
|
||||
type="checkbox"
|
||||
onChange={toggleAll}
|
||||
checked={toggleAllState === 1}
|
||||
/> All
|
||||
</label>
|
||||
{emojiList.map((emoji) => (
|
||||
<EmojiEntry
|
||||
key={emoji.shortcode}
|
||||
emoji={emoji}
|
||||
<form onSubmit={formSubmit}>
|
||||
<CheckList
|
||||
field={form.selectedEmoji}
|
||||
Component={EmojiEntry}
|
||||
localEmojiCodes={localEmojiCodes}
|
||||
updateEmoji={(value) => updateEmoji(emoji.shortcode, value)}
|
||||
checked={emojiState[emoji.shortcode].checked}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<CategorySelect
|
||||
value={category}
|
||||
categoryState={categoryState}
|
||||
field={form.category}
|
||||
/>
|
||||
|
||||
<div className="action-buttons row">
|
||||
<button disabled={!someSelected} onClick={() => submit("copy")}>{patchResult.isLoading ? "Processing..." : "Copy to local emoji"}</button>
|
||||
<button disabled={!someSelected} onClick={() => submit("disable")} className="danger">{patchResult.isLoading ? "Processing..." : "Disable"}</button>
|
||||
<MutationButton name="copy" label="Copy to local emoji" result={result} showError={false} {...buttonsInactive} />
|
||||
<MutationButton name="disable" label="Disable" result={result} className="button danger" showError={false} {...buttonsInactive} />
|
||||
</div>
|
||||
{err && <div className="error">
|
||||
{err}
|
||||
</div>}
|
||||
{patchResult.isSuccess && <div>
|
||||
Action applied to {patchResult.data.length} emoji
|
||||
</div>}
|
||||
{result.error && (
|
||||
Array.isArray(result.error)
|
||||
? <ErrorList errors={result.error} />
|
||||
: <Error error={result.error} />
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmojiEntry({ emoji, localEmojiCodes, updateEmoji, checked }) {
|
||||
const [onShortcodeChange, _resetShortcode, { shortcode, shortcodeRef, shortcodeValid }] = useTextInput("shortcode", {
|
||||
function ErrorList({ errors }) {
|
||||
return (
|
||||
<div className="error">
|
||||
One or multiple emoji failed to process:
|
||||
{errors.map(([shortcode, err]) => (
|
||||
<div key={shortcode}>
|
||||
<b>{shortcode}:</b> {err}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmojiEntry({ entry: emoji, localEmojiCodes, onChange }) {
|
||||
const shortcodeField = useTextInput("shortcode", {
|
||||
defaultValue: emoji.shortcode,
|
||||
validator: function validateShortcode(code) {
|
||||
return (checked && localEmojiCodes.has(code))
|
||||
return (emoji.checked && localEmojiCodes.has(code))
|
||||
? "Shortcode already in use"
|
||||
: "";
|
||||
}
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
updateEmoji({ valid: shortcodeValid });
|
||||
onChange({ valid: shortcodeField.valid });
|
||||
/* eslint-disable-next-line react-hooks/exhaustive-deps */
|
||||
}, [shortcodeValid]);
|
||||
}, [shortcodeField.valid]);
|
||||
|
||||
return (
|
||||
<label key={emoji.shortcode} className="row">
|
||||
<input
|
||||
type="checkbox"
|
||||
onChange={(e) => updateEmoji({ checked: e.target.checked })}
|
||||
checked={checked}
|
||||
/>
|
||||
<>
|
||||
<img className="emoji" src={emoji.url} title={emoji.shortcode} />
|
||||
|
||||
<input
|
||||
type="text"
|
||||
id="shortcode"
|
||||
name="Shortcode"
|
||||
ref={shortcodeRef}
|
||||
<TextInput
|
||||
field={shortcodeField}
|
||||
onChange={(e) => {
|
||||
onShortcodeChange(e);
|
||||
updateEmoji({ shortcode: e.target.value, checked: true });
|
||||
shortcodeField.onChange(e);
|
||||
onChange({ shortcode: e.target.value, checked: true });
|
||||
}}
|
||||
value={shortcode}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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";
|
||||
|
||||
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 (
|
||||
<div className="messagebutton">
|
||||
<button type="submit" onClick={onClick}>{ label }</button>
|
||||
{errorMsg.length > 0 &&
|
||||
<div className="error accent">{errorMsg}</div>
|
||||
}
|
||||
{statusMsg.length > 0 &&
|
||||
<div className="accent">{statusMsg}</div>
|
||||
}
|
||||
</div>
|
||||
<Switch>
|
||||
<Route path={`${baseUrl}/import-export/:list?`}>
|
||||
<InstanceImportExport />
|
||||
</Route>
|
||||
|
||||
<Route path={`${baseUrl}/:domain`}>
|
||||
<InstanceDetail baseUrl={baseUrl} />
|
||||
</Route>
|
||||
|
||||
<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";
|
||||
|
||||
const React = require("react");
|
||||
const Redux = require("react-redux");
|
||||
|
||||
const Submit = require("../components/submit");
|
||||
const query = require("../lib/query");
|
||||
|
||||
const api = require("../lib/api");
|
||||
const submit = require("../lib/submit");
|
||||
const {
|
||||
useTextInput,
|
||||
useFileInput
|
||||
} = require("../lib/form");
|
||||
|
||||
const adminActions = require("../redux/reducers/instances").actions;
|
||||
const useFormSubmit = require("../lib/form/submit");
|
||||
|
||||
const {
|
||||
TextInput,
|
||||
TextArea,
|
||||
File
|
||||
} = require("../components/form-fields").formFields(adminActions.setAdminSettingsVal, (state) => state.instances.adminSettings);
|
||||
FileInput
|
||||
} = require("../components/form/inputs");
|
||||
|
||||
const FormWithData = require("../lib/form/form-with-data");
|
||||
const MutationButton = require("../components/form/mutation-button");
|
||||
|
||||
module.exports = function AdminSettings() {
|
||||
const dispatch = Redux.useDispatch();
|
||||
const instance = Redux.useSelector(state => state.instances.adminSettings);
|
||||
|
||||
const [errorMsg, setError] = React.useState("");
|
||||
const [statusMsg, setStatus] = React.useState("");
|
||||
|
||||
const updateSettings = submit(
|
||||
() => dispatch(api.admin.updateInstance()),
|
||||
{setStatus, setError}
|
||||
return (
|
||||
<FormWithData
|
||||
dataQuery={query.useInstanceQuery}
|
||||
DataForm={AdminSettingsForm}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
function AdminSettingsForm({ data: instance }) {
|
||||
const form = {
|
||||
title: useTextInput("title", { defaultValue: instance.title }),
|
||||
thumbnail: useFileInput("thumbnail", { withPreview: true }),
|
||||
thumbnailDesc: useTextInput("thumbnail_description", { defaultValue: instance.thumbnail_description }),
|
||||
shortDesc: useTextInput("short_description", { defaultValue: instance.short_description }),
|
||||
description: useTextInput("description", { defaultValue: instance.description }),
|
||||
contactUser: useTextInput("contact_username", { defaultValue: instance.contact_account?.username }),
|
||||
contactEmail: useTextInput("contact_email", { defaultValue: instance.email }),
|
||||
terms: useTextInput("terms", { defaultValue: instance.terms })
|
||||
};
|
||||
|
||||
const [submitForm, result] = useFormSubmit(form, query.useUpdateInstanceMutation());
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form onSubmit={submitForm}>
|
||||
<h1>Instance Settings</h1>
|
||||
<TextInput
|
||||
id="title"
|
||||
name="Title"
|
||||
placeHolder="My GoToSocial instance"
|
||||
field={form.title}
|
||||
label="Title"
|
||||
placeholder="My GoToSocial instance"
|
||||
/>
|
||||
|
||||
<div className="file-upload">
|
||||
<h3>Instance thumbnail</h3>
|
||||
<div>
|
||||
<img className="preview avatar" src={instance.thumbnail} alt={instance.thumbnail ? `Thumbnail image for the instance` : "No instance thumbnail image set"} />
|
||||
<File
|
||||
id="thumbnail"
|
||||
fileType="image/*"
|
||||
<img className="preview avatar" src={form.thumbnail.previewValue ?? instance.thumbnail} alt={form.thumbnailDesc.value ?? (instance.thumbnail ? `Thumbnail image for the instance` : "No instance thumbnail image set")} />
|
||||
<FileInput
|
||||
field={form.thumbnail}
|
||||
accept="image/*"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TextInput
|
||||
id="thumbnail_description"
|
||||
name="Instance thumbnail description"
|
||||
placeHolder="A cute little picture of a smiling sloth."
|
||||
field={form.thumbnailDesc}
|
||||
label="Instance thumbnail description"
|
||||
placeholder="A cute drawing of a smiling sloth."
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
id="short_description"
|
||||
name="Short description"
|
||||
placeHolder="A small testing instance for the GoToSocial alpha."
|
||||
field={form.shortDesc}
|
||||
label="Short description"
|
||||
placeholder="A small testing instance for the GoToSocial alpha software."
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
id="description"
|
||||
name="Full description"
|
||||
placeHolder="A small testing instance for the GoToSocial alpha."
|
||||
field={form.description}
|
||||
label="Full description"
|
||||
placeholder="A small testing instance for the GoToSocial alpha software. Just trying it out, my main instance is https://example.com"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
id="contact_account.username"
|
||||
name="Contact user (local account username)"
|
||||
placeHolder="admin"
|
||||
field={form.contactUser}
|
||||
label="Contact user (local account username)"
|
||||
placeholder="admin"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
id="email"
|
||||
name="Contact email"
|
||||
placeHolder="admin@example.com"
|
||||
field={form.contactEmail}
|
||||
label="Contact email"
|
||||
placeholder="admin@example.com"
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
id="terms"
|
||||
name="Terms & Conditions"
|
||||
placeHolder=""
|
||||
field={form.terms}
|
||||
label="Terms & Conditions"
|
||||
placeholder=""
|
||||
/>
|
||||
|
||||
<Submit onClick={updateSettings} label="Save" errorMsg={errorMsg} statusMsg={statusMsg} />
|
||||
</div>
|
||||
<MutationButton label="Save" result={result} />
|
||||
</form>
|
||||
);
|
||||
};
|
||||
}
|
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>
|
||||
);
|
||||
};
|
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,
|
||||
} = require("ariakit/combobox");
|
||||
|
||||
module.exports = function ComboBox({state, items, label, placeHolder, children}) {
|
||||
module.exports = function ComboBox({ field, items, label, children, ...inputProps }) {
|
||||
return (
|
||||
<div className="form-field combobox-wrapper">
|
||||
<label>
|
||||
{label}
|
||||
<div className="row">
|
||||
<Combobox
|
||||
state={state}
|
||||
placeholder={placeHolder}
|
||||
state={field.state}
|
||||
className="combobox input"
|
||||
{...inputProps}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
</label>
|
||||
<ComboboxPopover state={state} className="popover">
|
||||
<ComboboxPopover state={field.state} className="popover">
|
||||
{items.map(([key, value]) => (
|
||||
<ComboboxItem className="combobox-item" key={key} value={key}>
|
||||
{value}
|
||||
|
|
|
@ -20,7 +20,7 @@
|
|||
|
||||
const React = require("react");
|
||||
|
||||
module.exports = function ErrorFallback({error, resetErrorBoundary}) {
|
||||
function ErrorFallback({ error, resetErrorBoundary }) {
|
||||
return (
|
||||
<div className="error">
|
||||
<p>
|
||||
|
@ -41,4 +41,43 @@ module.exports = function ErrorFallback({error, resetErrorBoundary}) {
|
|||
</p>
|
||||
</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";
|
||||
|
||||
const React = require("react");
|
||||
const Redux = require("react-redux");
|
||||
|
||||
module.exports = function FakeProfile({}) {
|
||||
const account = Redux.useSelector(state => state.user.profile);
|
||||
|
||||
module.exports = function FakeProfile({ avatar, header, display_name, username, role }) {
|
||||
return ( // Keep in sync with web/template/profile.tmpl
|
||||
<div className="profile">
|
||||
<div className="headerimage">
|
||||
<img className="headerpreview" src={account.header} alt={account.header ? `header image for ${account.username}` : "None set"} />
|
||||
<img className="headerpreview" src={header} alt={header ? `header image for ${username}` : "None set"} />
|
||||
</div>
|
||||
<div className="basic">
|
||||
<div id="profile-basic-filler2"></div>
|
||||
<span className="avatar"><img className="avatarpreview" src={account.avatar} alt={account.avatar ? `avatar image for ${account.username}` : "None set"} /></span>
|
||||
<div className="displayname">{account.display_name.trim().length > 0 ? account.display_name : account.username}</div>
|
||||
<span className="avatar"><img className="avatarpreview" src={avatar} alt={avatar ? `avatar image for ${username}` : "None set"} /></span>
|
||||
<div className="displayname">{display_name.trim().length > 0 ? display_name : username}</div>
|
||||
<div className="usernamecontainer">
|
||||
<div className="username"><span>@{account.username}</span></div>
|
||||
{(account.role && account.role != "user") &&
|
||||
<div className={`role ${account.role}`}>{account.role}</div>
|
||||
<div className="username"><span>@{username}</span></div>
|
||||
{(role && role != "user") &&
|
||||
<div className={`role ${role}`}>{role}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -19,10 +19,15 @@
|
|||
"use strict";
|
||||
|
||||
const React = require("react");
|
||||
const Redux = require("react-redux");
|
||||
|
||||
const query = require("../lib/query");
|
||||
|
||||
module.exports = function FakeToot({ children }) {
|
||||
const account = Redux.useSelector((state) => state.user.profile);
|
||||
const { data: account = {
|
||||
avatar: "/assets/default_avatars/GoToSocial_icon1.png",
|
||||
display_name: "",
|
||||
username: ""
|
||||
} } = query.useVerifyCredentialsQuery();
|
||||
|
||||
return (
|
||||
<div className="toot expanded">
|
||||
|
|
|
@ -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() {
|
||||
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>
|
||||
);
|
||||
};
|
|
@ -18,20 +18,16 @@
|
|||
|
||||
"use strict";
|
||||
|
||||
const Promise = require("bluebird");
|
||||
const React = require("react");
|
||||
const ReactDom = require("react-dom/client");
|
||||
const Redux = require("react-redux");
|
||||
const { Switch, Route, Redirect } = require("wouter");
|
||||
const { Provider } = require("react-redux");
|
||||
const { PersistGate } = require("redux-persist/integration/react");
|
||||
const { Switch, Route, Redirect } = require("wouter");
|
||||
|
||||
const query = require("./lib/query");
|
||||
|
||||
const { store, persistor } = require("./redux");
|
||||
const api = require("./lib/api");
|
||||
const oauth = require("./redux/reducers/oauth").actions;
|
||||
const { AuthenticationError } = require("./lib/errors");
|
||||
|
||||
const Login = require("./components/login");
|
||||
const AuthorizationGate = require("./components/authorization");
|
||||
const Loading = require("./components/loading");
|
||||
|
||||
require("./style.css");
|
||||
|
@ -46,7 +42,7 @@ const nav = {
|
|||
adminOnly: true,
|
||||
"Instance Settings": require("./admin/settings.js"),
|
||||
"Actions": require("./admin/actions"),
|
||||
"Federation": require("./admin/federation.js"),
|
||||
"Federation": require("./admin/federation"),
|
||||
},
|
||||
"Custom Emoji": {
|
||||
adminOnly: true,
|
||||
|
@ -57,123 +53,37 @@ const nav = {
|
|||
|
||||
const { sidebar, panelRouter } = require("./lib/get-views")(nav);
|
||||
|
||||
function App() {
|
||||
const dispatch = Redux.useDispatch();
|
||||
function App({ account }) {
|
||||
const isAdmin = account.role == "admin";
|
||||
const [logoutQuery] = query.useLogoutMutation();
|
||||
|
||||
const { loginState, isAdmin } = Redux.useSelector((state) => state.oauth);
|
||||
const reduxTempStatus = Redux.useSelector((state) => state.temporary.status);
|
||||
|
||||
const [errorMsg, setErrorMsg] = React.useState();
|
||||
const [tokenChecked, setTokenChecked] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (loginState == "login" || loginState == "callback") {
|
||||
Promise.try(() => {
|
||||
// Process OAUTH authorization token from URL if available
|
||||
if (loginState == "callback") {
|
||||
let urlParams = new URLSearchParams(window.location.search);
|
||||
let code = urlParams.get("code");
|
||||
|
||||
if (code == undefined) {
|
||||
setErrorMsg(new Error("Waiting for OAUTH callback but no ?code= provided. You can try logging in again:"));
|
||||
} else {
|
||||
return dispatch(api.oauth.tokenize(code));
|
||||
}
|
||||
}
|
||||
}).then(() => {
|
||||
// Fetch current instance info
|
||||
return dispatch(api.instance.fetch());
|
||||
}).then(() => {
|
||||
// Check currently stored auth token for validity if available
|
||||
return dispatch(api.user.fetchAccount());
|
||||
}).then(() => {
|
||||
setTokenChecked(true);
|
||||
|
||||
return dispatch(api.oauth.checkIfAdmin());
|
||||
}).catch((e) => {
|
||||
if (e instanceof AuthenticationError) {
|
||||
dispatch(oauth.remove());
|
||||
e.message = "Stored OAUTH token no longer valid, please log in again.";
|
||||
}
|
||||
setErrorMsg(e);
|
||||
console.error(e);
|
||||
});
|
||||
}
|
||||
}, [loginState, dispatch]);
|
||||
|
||||
let ErrorElement = null;
|
||||
if (errorMsg != undefined) {
|
||||
ErrorElement = (
|
||||
<div className="error">
|
||||
<b>{errorMsg.type}</b>
|
||||
<span>{errorMsg.message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const LogoutElement = (
|
||||
<button className="logout" onClick={() => { dispatch(api.oauth.logout()); }}>
|
||||
Log out
|
||||
</button>
|
||||
);
|
||||
|
||||
if (reduxTempStatus != undefined) {
|
||||
return (
|
||||
<section>
|
||||
{reduxTempStatus}
|
||||
</section>
|
||||
);
|
||||
} else if (tokenChecked && loginState == "login") {
|
||||
return (
|
||||
<>
|
||||
<div className="sidebar">
|
||||
{sidebar.all}
|
||||
{isAdmin && sidebar.admin}
|
||||
{LogoutElement}
|
||||
<button className="logout" onClick={logoutQuery}>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
<section className="with-sidebar">
|
||||
{ErrorElement}
|
||||
<Switch>
|
||||
{panelRouter.all}
|
||||
{isAdmin && panelRouter.admin}
|
||||
<Route> {/* default route */}
|
||||
<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() {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<PersistGate loading={<section><Loading /></section>} persistor={persistor}>
|
||||
<App />
|
||||
<AuthorizationGate App={App} />
|
||||
</PersistGate>
|
||||
</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";
|
||||
|
||||
const React = require("react");
|
||||
|
||||
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({
|
||||
defaultValue,
|
||||
gutter: 0,
|
||||
|
@ -31,11 +35,22 @@ module.exports = function useComboBoxInput({name, Name}, {validator, defaultValu
|
|||
state.setValue("");
|
||||
}
|
||||
|
||||
return [
|
||||
return Object.assign([
|
||||
state,
|
||||
reset,
|
||||
{
|
||||
[name]: state.value,
|
||||
name,
|
||||
[`${name}IsNew`]: isNew,
|
||||
[`set${Name}IsNew`]: setIsNew
|
||||
}
|
||||
];
|
||||
], {
|
||||
name,
|
||||
state,
|
||||
value: state.value,
|
||||
hasChanged: () => state.value != defaultValue,
|
||||
isNew,
|
||||
setIsNew,
|
||||
reset
|
||||
});
|
||||
};
|
|
@ -25,7 +25,7 @@ module.exports = function useFileInput({name, _Name}, {
|
|||
withPreview,
|
||||
maxSize,
|
||||
initialInfo = "no file selected"
|
||||
}) {
|
||||
} = {}) {
|
||||
const [file, setFile] = React.useState();
|
||||
const [imageURL, setImageURL] = React.useState();
|
||||
const [info, setInfo] = React.useState();
|
||||
|
@ -61,18 +61,31 @@ module.exports = function useFileInput({name, _Name}, {
|
|||
setInfo();
|
||||
}
|
||||
|
||||
return [
|
||||
onChange,
|
||||
reset,
|
||||
{
|
||||
[name]: file,
|
||||
[`${name}URL`]: imageURL,
|
||||
[`${name}Info`]: <span className="form-info">
|
||||
const infoComponent = (
|
||||
<span className="form-info">
|
||||
{info
|
||||
? info
|
||||
: initialInfo
|
||||
}
|
||||
</span>
|
||||
);
|
||||
|
||||
// Array / Object hybrid, for easier access in different contexts
|
||||
return Object.assign([
|
||||
onChange,
|
||||
reset,
|
||||
{
|
||||
[name]: file,
|
||||
[`${name}URL`]: imageURL,
|
||||
[`${name}Info`]: infoComponent,
|
||||
}
|
||||
];
|
||||
], {
|
||||
onChange,
|
||||
reset,
|
||||
name,
|
||||
value: file,
|
||||
previewValue: imageURL,
|
||||
hasChanged: () => file != undefined,
|
||||
infoComponent
|
||||
});
|
||||
};
|
|
@ -20,23 +20,20 @@
|
|||
|
||||
const React = require("react");
|
||||
|
||||
module.exports = function MutateButton({text, result}) {
|
||||
let buttonText = text;
|
||||
const Loading = require("../../components/loading");
|
||||
|
||||
if (result.isLoading) {
|
||||
buttonText = "Processing...";
|
||||
}
|
||||
// Wrap Form component inside component that fires the RTK Query call,
|
||||
// so Form will only be rendered when data is available to generate form-fields for
|
||||
module.exports = function FormWithData({ dataQuery, DataForm, queryArg, ...formProps }) {
|
||||
const { data, isLoading } = dataQuery(queryArg);
|
||||
|
||||
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}
|
||||
/>
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div>
|
||||
<Loading />
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return <DataForm data={data} {...formProps} />;
|
||||
}
|
||||
};
|
|
@ -18,25 +18,29 @@
|
|||
|
||||
"use strict";
|
||||
|
||||
const {createSlice} = require("@reduxjs/toolkit");
|
||||
const d = require("dotty");
|
||||
function capitalizeFirst(str) {
|
||||
return str.slice(0, 1).toUpperCase() + str.slice(1);
|
||||
}
|
||||
|
||||
module.exports = createSlice({
|
||||
name: "instances",
|
||||
initialState: {
|
||||
info: {},
|
||||
},
|
||||
reducers: {
|
||||
setNamedInstanceInfo: (state, {payload}) => {
|
||||
let [key, info] = payload;
|
||||
state.info[key] = info;
|
||||
},
|
||||
setInstanceInfo: (state, {payload}) => {
|
||||
state.current = payload;
|
||||
state.adminSettings = payload;
|
||||
},
|
||||
setAdminSettingsVal: (state, {payload: [key, val]}) => {
|
||||
d.put(state.adminSettings, key, val);
|
||||
function makeHook(func) {
|
||||
return (name, ...args) => func({
|
||||
name,
|
||||
Name: capitalizeFirst(name)
|
||||
}, ...args);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
useTextInput: makeHook(require("./text")),
|
||||
useFileInput: makeHook(require("./file")),
|
||||
useBoolInput: makeHook(require("./bool")),
|
||||
useRadioInput: makeHook(require("./radio")),
|
||||
useComboBoxInput: makeHook(require("./combo-box")),
|
||||
useCheckListInput: makeHook(require("./check-list")),
|
||||
useValue: function (name, value) {
|
||||
return {
|
||||
name,
|
||||
value,
|
||||
hasChanged: () => true // always included
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
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");
|
||||
|
||||
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 [valid, setValid] = React.useState(true);
|
||||
const textRef = React.useRef(null);
|
||||
|
@ -31,26 +31,37 @@ module.exports = function useTextInput({name, Name}, {validator, defaultValue=""
|
|||
}
|
||||
|
||||
function reset() {
|
||||
setText("");
|
||||
if (!dontReset) {
|
||||
setText(defaultValue);
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (validator) {
|
||||
if (validator && textRef.current) {
|
||||
let res = validator(text);
|
||||
setValid(res == "");
|
||||
textRef.current.setCustomValidity(res);
|
||||
textRef.current.reportValidity();
|
||||
}
|
||||
}, [text, textRef, validator]);
|
||||
|
||||
return [
|
||||
// Array / Object hybrid, for easier access in different contexts
|
||||
return Object.assign([
|
||||
onChange,
|
||||
reset,
|
||||
{
|
||||
[name]: text,
|
||||
[`${name}Ref`]: textRef,
|
||||
[`set${Name}`]: setText,
|
||||
[`${name}Valid`]: valid
|
||||
[`${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 { ErrorBoundary } = require("react-error-boundary");
|
||||
|
||||
const ErrorFallback = require("../components/error");
|
||||
const { ErrorFallback } = require("../components/error");
|
||||
const NavButton = require("../components/nav-button");
|
||||
|
||||
function urlSafe(str) {
|
||||
|
@ -64,7 +64,7 @@ module.exports = function getViews(struct) {
|
|||
}
|
||||
|
||||
panelRouterEl.push((
|
||||
<Route path={`${url}/:page?`} key={url}>
|
||||
<Route path={`${url}/:page*`} key={url}>
|
||||
<ErrorBoundary FallbackComponent={ErrorFallback} onReset={() => { }}>
|
||||
{/* FIXME: implement onReset */}
|
||||
<ViewComponent />
|
||||
|
|
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 });
|
|
@ -19,8 +19,23 @@
|
|||
"use strict";
|
||||
|
||||
const { createApi, fetchBaseQuery } = require("@reduxjs/toolkit/query/react");
|
||||
const { isPlainObject } = require("is-plain-object");
|
||||
|
||||
const { convertToForm } = require("../api");
|
||||
function convertToForm(obj) {
|
||||
const formData = new FormData();
|
||||
Object.entries(obj).forEach(([key, val]) => {
|
||||
if (isPlainObject(val)) {
|
||||
Object.entries(val).forEach(([key2, val2]) => {
|
||||
if (val2 != undefined) {
|
||||
formData.set(`${key}[${key2}]`, val2);
|
||||
}
|
||||
});
|
||||
} else if (val != undefined) {
|
||||
formData.set(key, val);
|
||||
}
|
||||
});
|
||||
return formData;
|
||||
}
|
||||
|
||||
function instanceBasedQuery(args, api, extraOptions) {
|
||||
const state = api.getState();
|
||||
|
@ -30,6 +45,13 @@ function instanceBasedQuery(args, api, extraOptions) {
|
|||
args.baseUrl = instance;
|
||||
}
|
||||
|
||||
if (args.discardEmpty) {
|
||||
if (args.body == undefined || Object.keys(args.body).length == 0) {
|
||||
return { data: null };
|
||||
}
|
||||
delete args.discardEmpty;
|
||||
}
|
||||
|
||||
if (args.asForm) {
|
||||
delete args.asForm;
|
||||
args.body = convertToForm(args.body);
|
||||
|
@ -50,6 +72,12 @@ function instanceBasedQuery(args, api, extraOptions) {
|
|||
module.exports = createApi({
|
||||
reducerPath: "api",
|
||||
baseQuery: instanceBasedQuery,
|
||||
tagTypes: ["Emojis"],
|
||||
endpoints: () => ({})
|
||||
tagTypes: ["Auth"],
|
||||
endpoints: (build) => ({
|
||||
instance: build.query({
|
||||
query: () => ({
|
||||
url: `/api/v1/instance`
|
||||
})
|
||||
})
|
||||
})
|
||||
});
|
|
@ -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});
|
|
@ -20,5 +20,7 @@
|
|||
|
||||
module.exports = {
|
||||
...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";
|
||||
|
||||
const Promise = require("bluebird");
|
||||
const { replaceCacheOnMutation } = require("./lib");
|
||||
const base = require("./base");
|
||||
|
||||
module.exports = function submit(func, {
|
||||
setStatus, setError,
|
||||
startStatus="PATCHing", successStatus="Saved!",
|
||||
onSuccess,
|
||||
onError
|
||||
}) {
|
||||
return function() {
|
||||
setStatus(startStatus);
|
||||
setError("");
|
||||
return Promise.try(() => {
|
||||
return func();
|
||||
}).then(() => {
|
||||
setStatus(successStatus);
|
||||
if (onSuccess != undefined) {
|
||||
return onSuccess();
|
||||
}
|
||||
}).catch((e) => {
|
||||
setError(e.message);
|
||||
setStatus("");
|
||||
console.error(e);
|
||||
if (onError != undefined) {
|
||||
onError(e);
|
||||
}
|
||||
const endpoints = (build) => ({
|
||||
updateCredentials: build.mutation({
|
||||
query: (formData) => ({
|
||||
method: "PATCH",
|
||||
url: `/api/v1/accounts/update_credentials`,
|
||||
asForm: true,
|
||||
body: formData,
|
||||
discardEmpty: true
|
||||
}),
|
||||
...replaceCacheOnMutation("verifyCredentials")
|
||||
}),
|
||||
passwordChange: build.mutation({
|
||||
query: (data) => ({
|
||||
method: "POST",
|
||||
url: `/api/v1/user/password_change`,
|
||||
body: data
|
||||
})
|
||||
})
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = base.injectEndpoints({ endpoints });
|
|
@ -34,18 +34,14 @@ const {
|
|||
const query = require("../lib/query/base");
|
||||
|
||||
const combinedReducers = combineReducers({
|
||||
oauth: require("./reducers/oauth").reducer,
|
||||
instances: require("./reducers/instances").reducer,
|
||||
temporary: require("./reducers/temporary").reducer,
|
||||
user: require("./reducers/user").reducer,
|
||||
admin: require("./reducers/admin").reducer,
|
||||
oauth: require("./oauth").reducer,
|
||||
[query.reducerPath]: query.reducer
|
||||
});
|
||||
|
||||
const persistedReducer = persistReducer({
|
||||
key: "gotosocial-settings",
|
||||
storage: require("redux-persist/lib/storage").default,
|
||||
stateReconciler: require("redux-persist/lib/stateReconciler/autoMergeLevel2").default,
|
||||
stateReconciler: require("redux-persist/lib/stateReconciler/autoMergeLevel1").default,
|
||||
whitelist: ["oauth"],
|
||||
}, combinedReducers);
|
||||
|
||||
|
@ -54,7 +50,7 @@ const store = configureStore({
|
|||
middleware: (getDefaultMiddleware) => {
|
||||
return getDefaultMiddleware({
|
||||
serializableCheck: {
|
||||
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER, "temporary/setScrollElement"]
|
||||
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER]
|
||||
}
|
||||
}).concat(query.middleware);
|
||||
}
|
||||
|
|
|
@ -23,30 +23,26 @@ const {createSlice} = require("@reduxjs/toolkit");
|
|||
module.exports = createSlice({
|
||||
name: "oauth",
|
||||
initialState: {
|
||||
loginState: 'none',
|
||||
loginState: 'none'
|
||||
},
|
||||
reducers: {
|
||||
setInstance: (state, { payload }) => {
|
||||
state.instance = payload;
|
||||
return {
|
||||
...state,
|
||||
...payload /* overrides instance, registration keys */
|
||||
};
|
||||
},
|
||||
setRegistration: (state, {payload}) => {
|
||||
state.registration = payload;
|
||||
authorize: (state) => {
|
||||
state.loginState = "callback";
|
||||
},
|
||||
setLoginState: (state, {payload}) => {
|
||||
state.loginState = payload;
|
||||
},
|
||||
login: (state, {payload}) => {
|
||||
setToken: (state, { payload }) => {
|
||||
state.token = `${payload.token_type} ${payload.access_token}`;
|
||||
state.loginState = "login";
|
||||
},
|
||||
remove: (state, { _payload }) => {
|
||||
delete state.token;
|
||||
delete state.registration;
|
||||
delete state.isAdmin;
|
||||
state.loginState = "none";
|
||||
},
|
||||
setAdmin: (state, {payload}) => {
|
||||
state.isAdmin = payload;
|
||||
state.loginState = "logout";
|
||||
}
|
||||
}
|
||||
});
|
|
@ -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/>.
|
||||
*/
|
||||
|
||||
/* Fork-Awesome 'fa-fw' fixed icon width
|
||||
keep in sync with https://github.com/ForkAwesome/Fork-Awesome/blob/a99579ae3e735ee70e51ed62dfcee3172b5b2db7/css/fork-awesome.css#L50
|
||||
*/
|
||||
$fa-fw: 1.28571429em;
|
||||
|
||||
body {
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
|
@ -40,7 +45,7 @@ section {
|
|||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
|
||||
& > div {
|
||||
& > div, & > form {
|
||||
border-left: 0.2rem solid $border-accent;
|
||||
padding-left: 0.4rem;
|
||||
display: flex;
|
||||
|
@ -50,7 +55,7 @@ section {
|
|||
|
||||
h2 {
|
||||
margin: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
&:only-child {
|
||||
|
@ -213,7 +218,7 @@ input, select, textarea {
|
|||
) !important;
|
||||
}
|
||||
|
||||
section.with-sidebar > div {
|
||||
section.with-sidebar > div, section.with-sidebar > form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
@ -223,19 +228,17 @@ section.with-sidebar > div {
|
|||
line-height: 1.5rem;
|
||||
}
|
||||
|
||||
button {
|
||||
width: auto;
|
||||
align-self: flex-start;
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
|
||||
input[type=checkbox] {
|
||||
justify-self: start;
|
||||
width: initial;
|
||||
}
|
||||
|
||||
input:read-only {
|
||||
border: none;
|
||||
}
|
||||
|
||||
input:invalid {
|
||||
border-color: red;
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
@ -337,15 +340,24 @@ section.with-sidebar > div {
|
|||
}
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-field label {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.form-field.file {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
}
|
||||
.form-field.file label {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
|
||||
.label {
|
||||
grid-column: 1 / span 2;
|
||||
}
|
||||
}
|
||||
|
||||
span.form-info {
|
||||
flex: 1 1 auto;
|
||||
|
@ -353,39 +365,70 @@ span.form-info {
|
|||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 0.3rem 0;
|
||||
font-weight: initial;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 0.5rem;
|
||||
|
||||
&.scrolling {
|
||||
max-height: 40rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.header, .entry {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
border: 0.1rem solid transparent; /* for alignment with .entry border padding */
|
||||
background: $gray2;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
input[type=checkbox] {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.entry {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
background: $settings-entry-bg;
|
||||
border: 0.1rem solid transparent;
|
||||
|
||||
&:nth-child(even) {
|
||||
background: $settings-entry-alternate-bg;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: $settings-entry-hover-bg;
|
||||
}
|
||||
|
||||
&:active, &:focus, &:hover {
|
||||
border-color: $fg-accent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-list {
|
||||
.header, .entry {
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.instance-list {
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.filter {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
|
||||
input {
|
||||
width: auto;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.entry {
|
||||
padding: 0.3rem;
|
||||
padding: 0.5rem;
|
||||
margin: 0.2rem 0;
|
||||
|
||||
#domain {
|
||||
|
@ -406,9 +449,12 @@ span.form-info {
|
|||
background: $settings-entry-bg;
|
||||
|
||||
.entry {
|
||||
padding: 0.5rem;
|
||||
flex-direction: column;
|
||||
|
||||
b {
|
||||
padding-left: 0.4rem;
|
||||
}
|
||||
|
||||
.emoji-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
@ -550,6 +596,7 @@ span.form-info {
|
|||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.emoji-detail {
|
||||
|
@ -566,6 +613,12 @@ span.form-info {
|
|||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
img {
|
||||
height: 8.5rem;
|
||||
width: 8.5rem;
|
||||
|
@ -576,15 +629,13 @@ span.form-info {
|
|||
}
|
||||
|
||||
.update-category {
|
||||
margin-bottom: 1rem;
|
||||
.combobox-wrapper button {
|
||||
font-size: 1rem;
|
||||
margin: 0.15rem 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
margin-top: 0.4rem;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -607,36 +658,18 @@ span.form-info {
|
|||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
||||
& > span {
|
||||
margin-bottom: -1rem;
|
||||
span {
|
||||
margin-bottom: -0.5rem;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.emoji-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
& > * {
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: $gray2;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.row {
|
||||
.checkbox-list {
|
||||
.entry {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto 1fr;
|
||||
|
||||
&:hover {
|
||||
background: $settings-entry-hover-bg;
|
||||
}
|
||||
}
|
||||
|
||||
.emoji {
|
||||
|
@ -647,3 +680,93 @@ 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";
|
||||
|
||||
const React = require("react");
|
||||
const Redux = require("react-redux");
|
||||
|
||||
const Submit = require("../components/submit");
|
||||
const query = require("../lib/query");
|
||||
|
||||
const api = require("../lib/api");
|
||||
const user = require("../redux/reducers/user").actions;
|
||||
const submit = require("../lib/submit");
|
||||
const {
|
||||
useTextInput,
|
||||
useFileInput,
|
||||
useBoolInput
|
||||
} = require("../lib/form");
|
||||
|
||||
const FakeProfile = require("../components/fake-profile");
|
||||
const { formFields } = require("../components/form-fields");
|
||||
const useFormSubmit = require("../lib/form/submit");
|
||||
|
||||
const {
|
||||
TextInput,
|
||||
TextArea,
|
||||
Checkbox,
|
||||
File
|
||||
} = formFields(user.setProfileVal, (state) => state.user.profile);
|
||||
FileInput,
|
||||
Checkbox
|
||||
} = require("../components/form/inputs");
|
||||
|
||||
const FormWithData = require("../lib/form/form-with-data");
|
||||
const FakeProfile = require("../components/fake-profile");
|
||||
const MutationButton = require("../components/form/mutation-button");
|
||||
|
||||
module.exports = function UserProfile() {
|
||||
const dispatch = Redux.useDispatch();
|
||||
const instance = Redux.useSelector(state => state.instances.current);
|
||||
|
||||
const allowCustomCSS = instance.configuration.accounts.allow_custom_css;
|
||||
|
||||
const [errorMsg, setError] = React.useState("");
|
||||
const [statusMsg, setStatus] = React.useState("");
|
||||
|
||||
const saveProfile = submit(
|
||||
() => dispatch(api.user.updateProfile()),
|
||||
{setStatus, setError}
|
||||
return (
|
||||
<FormWithData
|
||||
dataQuery={query.useVerifyCredentialsQuery}
|
||||
DataForm={UserProfileForm}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="user-profile">
|
||||
<form className="user-profile" onSubmit={submitForm}>
|
||||
<h1>Profile</h1>
|
||||
<div className="overview">
|
||||
<FakeProfile/>
|
||||
<FakeProfile
|
||||
avatar={form.avatar.previewValue ?? profile.avatar}
|
||||
header={form.header.previewValue ?? profile.header}
|
||||
display_name={form.displayName.value ?? profile.username}
|
||||
username={profile.username}
|
||||
role={profile.role}
|
||||
/>
|
||||
<div className="files">
|
||||
<div>
|
||||
<h3>Header</h3>
|
||||
<File
|
||||
id="header"
|
||||
fileType="image/*"
|
||||
<FileInput
|
||||
field={form.header}
|
||||
accept="image/*"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Avatar</h3>
|
||||
<File
|
||||
id="avatar"
|
||||
fileType="image/*"
|
||||
<FileInput
|
||||
field={form.avatar}
|
||||
accept="image/*"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TextInput
|
||||
id="display_name"
|
||||
name="Name"
|
||||
placeHolder="A GoToSocial user"
|
||||
field={form.displayName}
|
||||
label="Name"
|
||||
placeholder="A GoToSocial user"
|
||||
/>
|
||||
<TextArea
|
||||
id="source.note"
|
||||
name="Bio"
|
||||
placeHolder="Just trying out GoToSocial, my pronouns are they/them and I like sloths."
|
||||
field={form.note}
|
||||
label="Bio"
|
||||
placeholder="Just trying out GoToSocial, my pronouns are they/them and I like sloths."
|
||||
rows={8}
|
||||
/>
|
||||
<Checkbox
|
||||
id="locked"
|
||||
name="Manually approve follow requests"
|
||||
field={form.locked}
|
||||
label="Manually approve follow requests"
|
||||
/>
|
||||
<Checkbox
|
||||
id="enable_rss"
|
||||
name="Enable RSS feed of Public posts"
|
||||
field={form.enableRSS}
|
||||
label="Enable RSS feed of Public posts"
|
||||
/>
|
||||
{!allowCustomCSS ? null :
|
||||
<TextArea
|
||||
id="custom_css"
|
||||
name="Custom CSS"
|
||||
field={form.customCSS}
|
||||
label="Custom CSS"
|
||||
className="monospace"
|
||||
rows={8}
|
||||
>
|
||||
<a href="https://docs.gotosocial.org/en/latest/user_guide/custom_css" target="_blank" className="moreinfolink" rel="noreferrer">Learn more about custom profile CSS (opens in a new tab)</a>
|
||||
</TextArea>
|
||||
}
|
||||
<Submit onClick={saveProfile} label="Save profile info" errorMsg={errorMsg} statusMsg={statusMsg} />
|
||||
</div>
|
||||
<MutationButton label="Save profile info" result={result} />
|
||||
</form>
|
||||
);
|
||||
};
|
||||
}
|
|
@ -18,42 +18,63 @@
|
|||
|
||||
"use strict";
|
||||
|
||||
const Promise = require("bluebird");
|
||||
const React = require("react");
|
||||
const Redux = require("react-redux");
|
||||
|
||||
const api = require("../lib/api");
|
||||
const user = require("../redux/reducers/user").actions;
|
||||
const submit = require("../lib/submit");
|
||||
|
||||
const Languages = require("../components/languages");
|
||||
const Submit = require("../components/submit");
|
||||
const query = require("../lib/query");
|
||||
|
||||
const {
|
||||
useTextInput,
|
||||
useBoolInput
|
||||
} = require("../lib/form");
|
||||
|
||||
const useFormSubmit = require("../lib/form/submit");
|
||||
|
||||
const {
|
||||
Checkbox,
|
||||
Select,
|
||||
} = require("../components/form-fields").formFields(user.setSettingsVal, (state) => state.user.settings);
|
||||
TextInput,
|
||||
Checkbox
|
||||
} = require("../components/form/inputs");
|
||||
|
||||
const FormWithData = require("../lib/form/form-with-data");
|
||||
const Languages = require("../components/languages");
|
||||
const MutationButton = require("../components/form/mutation-button");
|
||||
|
||||
module.exports = function UserSettings() {
|
||||
const dispatch = Redux.useDispatch();
|
||||
|
||||
const [errorMsg, setError] = React.useState("");
|
||||
const [statusMsg, setStatus] = React.useState("");
|
||||
|
||||
const updateSettings = submit(
|
||||
() => dispatch(api.user.updateSettings()),
|
||||
{setStatus, setError}
|
||||
return (
|
||||
<FormWithData
|
||||
dataQuery={query.useVerifyCredentialsQuery}
|
||||
DataForm={UserSettingsForm}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<>
|
||||
<div className="user-settings">
|
||||
<form className="user-settings" onSubmit={submitForm}>
|
||||
<h1>Post settings</h1>
|
||||
<Select id="source.language" name="Default post language" options={
|
||||
<Select field={form.language} label="Default post language" options={
|
||||
<Languages />
|
||||
}>
|
||||
</Select>
|
||||
<Select id="source.privacy" name="Default post privacy" options={
|
||||
<Select field={form.defaultPrivacy} label="Default post privacy" options={
|
||||
<>
|
||||
<option value="private">Private / followers-only</option>
|
||||
<option value="unlisted">Unlisted</option>
|
||||
|
@ -62,7 +83,7 @@ module.exports = function UserSettings() {
|
|||
}>
|
||||
<a href="https://docs.gotosocial.org/en/latest/user_guide/posts/#privacy-settings" target="_blank" className="moreinfolink" rel="noreferrer">Learn more about post privacy settings (opens in a new tab)</a>
|
||||
</Select>
|
||||
<Select id="source.status_format" name="Default post (and bio) format" options={
|
||||
<Select field={form.format} label="Default post (and bio) format" options={
|
||||
<>
|
||||
<option value="plain">Plain (default)</option>
|
||||
<option value="markdown">Markdown</option>
|
||||
|
@ -71,70 +92,50 @@ module.exports = function UserSettings() {
|
|||
<a href="https://docs.gotosocial.org/en/latest/user_guide/posts/#input-types" target="_blank" className="moreinfolink" rel="noreferrer">Learn more about post format settings (opens in a new tab)</a>
|
||||
</Select>
|
||||
<Checkbox
|
||||
id="source.sensitive"
|
||||
name="Mark my posts as sensitive by default"
|
||||
field={form.isSensitive}
|
||||
label="Mark my posts as sensitive by default"
|
||||
/>
|
||||
|
||||
<Submit onClick={updateSettings} label="Save post settings" errorMsg={errorMsg} statusMsg={statusMsg}/>
|
||||
</div>
|
||||
<MutationButton label="Save settings" result={result} />
|
||||
</form>
|
||||
<div>
|
||||
<PasswordChange />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function PasswordChange() {
|
||||
const dispatch = Redux.useDispatch();
|
||||
|
||||
const [errorMsg, setError] = React.useState("");
|
||||
const [statusMsg, setStatus] = React.useState("");
|
||||
|
||||
const [oldPassword, setOldPassword] = React.useState("");
|
||||
const [newPassword, setNewPassword] = React.useState("");
|
||||
const [newPasswordConfirm, setNewPasswordConfirm] = React.useState("");
|
||||
|
||||
function changePassword() {
|
||||
if (newPassword !== newPasswordConfirm) {
|
||||
setError("New password and confirm new password did not match!");
|
||||
return;
|
||||
const form = {
|
||||
oldPassword: useTextInput("old_password"),
|
||||
newPassword: useTextInput("old_password", {
|
||||
validator(val) {
|
||||
if (val != "" && val == form.oldPassword.value) {
|
||||
return "New password same as old password";
|
||||
}
|
||||
|
||||
setStatus("PATCHing");
|
||||
setError("");
|
||||
return Promise.try(() => {
|
||||
let data = {
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword
|
||||
return "";
|
||||
}
|
||||
})
|
||||
};
|
||||
return dispatch(api.apiCall("POST", "/api/v1/user/password_change", data, "form"));
|
||||
}).then(() => {
|
||||
setStatus("Saved!");
|
||||
setOldPassword("");
|
||||
setNewPassword("");
|
||||
setNewPasswordConfirm("");
|
||||
}).catch((e) => {
|
||||
setError(e.message);
|
||||
setStatus("");
|
||||
});
|
||||
|
||||
const verifyNewPassword = useTextInput("verifyNewPassword", {
|
||||
validator(val) {
|
||||
if (val != "" && val != form.newPassword.value) {
|
||||
return "Passwords do not match";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
});
|
||||
|
||||
const [submitForm, result] = useFormSubmit(form, query.usePasswordChangeMutation());
|
||||
|
||||
return (
|
||||
<>
|
||||
<form className="change-password" onSubmit={submitForm}>
|
||||
<h1>Change password</h1>
|
||||
<div className="labelinput">
|
||||
<label htmlFor="password">Current password</label>
|
||||
<input name="password" id="password" type="password" autoComplete="current-password" value={oldPassword} onChange={(e) => setOldPassword(e.target.value)} />
|
||||
</div>
|
||||
<div className="labelinput">
|
||||
<label htmlFor="new-password">New password</label>
|
||||
<input name="new-password" id="new-password" type="password" autoComplete="new-password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
|
||||
</div>
|
||||
<div className="labelinput">
|
||||
<label htmlFor="confirm-new-password">Confirm new password</label>
|
||||
<input name="confirm-new-password" id="confirm-new-password" type="password" autoComplete="new-password" value={newPasswordConfirm} onChange={(e) => setNewPasswordConfirm(e.target.value)} />
|
||||
</div>
|
||||
<Submit onClick={changePassword} label="Save new password" errorMsg={errorMsg} statusMsg={statusMsg}/>
|
||||
</>
|
||||
<TextInput type="password" field={form.oldPassword} label="Current password" />
|
||||
<TextInput type="password" field={form.newPassword} label="New password" />
|
||||
<TextInput type="password" field={verifyNewPassword} label="Confirm new password" />
|
||||
<MutationButton label="Change password" result={result} />
|
||||
</form>
|
||||
);
|
||||
}
|
|
@ -2463,11 +2463,6 @@ domain-browser@^1.2.0:
|
|||
resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda"
|
||||
integrity sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA==
|
||||
|
||||
dotty@^0.1.2:
|
||||
version "0.1.2"
|
||||
resolved "https://registry.yarnpkg.com/dotty/-/dotty-0.1.2.tgz#512d44cc4111a724931226259297f235e8484f6f"
|
||||
integrity sha512-V0EWmKeH3DEhMwAZ+8ZB2Ao4OK6p++Z0hsDtZq3N0+0ZMVqkzrcEGROvOnZpLnvBg5PTNG23JEDLAm64gPaotQ==
|
||||
|
||||
duplexer2@^0.1.2, duplexer2@~0.1.0, duplexer2@~0.1.2, duplexer2@~0.1.4:
|
||||
version "0.1.4"
|
||||
resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1"
|
||||
|
@ -2653,6 +2648,13 @@ escodegen@^1.11.1:
|
|||
optionalDependencies:
|
||||
source-map "~0.6.1"
|
||||
|
||||
eslint-plugin-license-header@^0.6.0:
|
||||
version "0.6.0"
|
||||
resolved "https://registry.yarnpkg.com/eslint-plugin-license-header/-/eslint-plugin-license-header-0.6.0.tgz#81b0bab59da5a752d3a129f04bd0ca35bb6b07a2"
|
||||
integrity sha512-IEywStBWaDBDMkogYoKUAdaOuomZ+YaQmdoSD2vHmXobekM+XuP6SWLlvwUUhIbdocn3MTlb5CUJ8E4VHz1c/w==
|
||||
dependencies:
|
||||
requireindex "^1.2.0"
|
||||
|
||||
eslint-plugin-react-hooks@^4.6.0:
|
||||
version "4.6.0"
|
||||
resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz#4c3e697ad95b77e93f8646aaa1630c1ba607edd3"
|
||||
|
@ -4631,6 +4633,11 @@ remove-accents@0.4.2:
|
|||
resolved "https://registry.yarnpkg.com/remove-accents/-/remove-accents-0.4.2.tgz#0a43d3aaae1e80db919e07ae254b285d9e1c7bb5"
|
||||
integrity sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA==
|
||||
|
||||
requireindex@^1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/requireindex/-/requireindex-1.2.0.tgz#3463cdb22ee151902635aa6c9535d4de9c2ef1ef"
|
||||
integrity sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==
|
||||
|
||||
requires-port@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff"
|
||||
|
|
Loading…
Reference in a new issue