refactor federation/suspend (overview, detail)
This commit is contained in:
parent
774cb78732
commit
4cbfa77907
|
@ -47,7 +47,11 @@ $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) */
|
||||
|
||||
$info-fg: $gray1;
|
||||
$info-bg: #b3ddff;
|
||||
$info-link: $error-link;
|
||||
|
||||
$fg: $white1;
|
||||
$bg: $gray1;
|
||||
|
@ -92,6 +96,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 +112,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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -54,7 +54,7 @@ module.exports = function AdminActionPanel() {
|
|||
min="0"
|
||||
placeholder="30"
|
||||
/>
|
||||
<MutationButton text="Remove old media" result={mediaCleanupResult} />
|
||||
<MutationButton label="Remove old media" result={mediaCleanupResult} />
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -86,7 +86,7 @@ function EmojiDetail({emoji}) {
|
|||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (category != emoji.category && !categoryState.open && !isNewCategory && category.trim().length > 0) {
|
||||
if (category != emoji.category && !categoryState.open && !isNewCategory && category?.trim().length > 0) {
|
||||
modifyEmoji({ id: emoji.id, category: category.trim() });
|
||||
}
|
||||
}, [isNewCategory, category, categoryState.open, emoji.category, emoji.id, modifyEmoji]);
|
||||
|
|
|
@ -161,7 +161,7 @@ module.exports = function NewEmojiForm({ emoji }) {
|
|||
categoryState={categoryState}
|
||||
/>
|
||||
|
||||
<MutationButton text="Upload emoji" result={result} />
|
||||
<MutationButton label="Upload emoji" result={result} />
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,386 +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";
|
||||
|
||||
module.exports = function AdminSettings() {
|
||||
const dispatch = Redux.useDispatch();
|
||||
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) => {
|
||||
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>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,146 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021-2022 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.find((block) => block.domain == 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>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021-2022 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,
|
||||
useBoolInput,
|
||||
useFileInput
|
||||
} = require("../../lib/form");
|
||||
|
||||
const useFormSubmit = require("../../lib/form/submit");
|
||||
|
||||
const {
|
||||
TextInput,
|
||||
TextArea,
|
||||
Checkbox,
|
||||
FileInput
|
||||
} = require("../../components/form/inputs");
|
||||
const FormWithData = require("../../lib/form/form-with-data");
|
||||
|
||||
module.exports = function ImportExport() {
|
||||
return (
|
||||
<div className="import-export">
|
||||
<h2>Import / Export</h2>
|
||||
<FormWithData
|
||||
dataQuery={query.useInstanceBlocksQuery}
|
||||
DataForm={ImportExportForm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function ImportExportForm({ data: blockedInstances }) {
|
||||
const form = {
|
||||
list: useTextInput("list"),
|
||||
obfuscate: useBoolInput("obfuscate"),
|
||||
commentPrivate: useTextInput("private_comment"),
|
||||
commentPublic: useTextInput("public_comment"),
|
||||
json: useFileInput("json")
|
||||
};
|
||||
|
||||
return (
|
||||
<form>
|
||||
<TextArea
|
||||
field={form.list}
|
||||
label="Domains, one per line"
|
||||
placeholder={`google.com\nfacebook.com`}
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
field={form.commentPrivate}
|
||||
label="Private comment"
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
field={form.commentPublic}
|
||||
label="Public comment"
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
field={form.obfuscate}
|
||||
label="Obfuscate domain in public lists"
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
|
@ -18,31 +18,26 @@
|
|||
|
||||
"use strict";
|
||||
|
||||
const Promise = require("bluebird");
|
||||
const React = require("react");
|
||||
const { Switch, Route } = require("wouter");
|
||||
|
||||
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 baseUrl = `/settings/admin/federation`;
|
||||
|
||||
const InstanceOverview = require("./overview");
|
||||
const InstanceDetail = require("./detail");
|
||||
|
||||
module.exports = function Federation({ }) {
|
||||
return (
|
||||
<Switch>
|
||||
{/* <Route path={`${baseUrl}/import-export`}>
|
||||
<InstanceImportExport />
|
||||
</Route> */}
|
||||
|
||||
<Route path={`${baseUrl}/:domain`}>
|
||||
<InstanceDetail baseUrl={baseUrl} />
|
||||
</Route>
|
||||
|
||||
<InstanceOverview baseUrl={baseUrl} />
|
||||
</Switch>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,97 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021-2022 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");
|
||||
const ImportExport = require("./import-export");
|
||||
|
||||
module.exports = function InstanceOverview({ baseUrl }) {
|
||||
const { data: blockedInstances = [], isLoading } = query.useInstanceBlocksQuery();
|
||||
|
||||
const [_location, setLocation] = useLocation();
|
||||
|
||||
const filterField = useTextInput("filter");
|
||||
const filter = filterField.value;
|
||||
|
||||
const filteredInstances = React.useMemo(() => {
|
||||
return matchSorter(Object.values(blockedInstances), filter, { keys: ["domain"] });
|
||||
}, [blockedInstances, filter]);
|
||||
|
||||
let filtered = blockedInstances.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>
|
||||
<span>
|
||||
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.
|
||||
</span>
|
||||
<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>
|
||||
{blockedInstances.length} blocked instance{blockedInstances.length != 1 ? "s" : ""} {filtered > 0 && `(${filtered} filtered)`}
|
||||
</span>
|
||||
<div className="list">
|
||||
{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>
|
||||
|
||||
<ImportExport />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -59,7 +59,7 @@ function AdminSettingsForm({data: instance}) {
|
|||
terms: useTextInput("terms", { defaultValue: instance.terms })
|
||||
};
|
||||
|
||||
const [result, submitForm] = useFormSubmit(form, query.useUpdateInstanceMutation());
|
||||
const [submitForm, result] = useFormSubmit(form, query.useUpdateInstanceMutation());
|
||||
|
||||
return (
|
||||
<form onSubmit={submitForm}>
|
||||
|
@ -117,7 +117,7 @@ function AdminSettingsForm({data: instance}) {
|
|||
placeholder=""
|
||||
/>
|
||||
|
||||
<MutationButton text="Save" result={result}/>
|
||||
<MutationButton label="Save" result={result} />
|
||||
</form>
|
||||
);
|
||||
}
|
|
@ -20,24 +20,28 @@
|
|||
|
||||
const React = require("react");
|
||||
|
||||
module.exports = function MutateButton({text, result}) {
|
||||
let buttonText = text;
|
||||
module.exports = function MutationButton({ label, result, disabled, ...inputProps }) {
|
||||
let iconClass = "";
|
||||
|
||||
console.log(label, result);
|
||||
|
||||
if (result.isLoading) {
|
||||
buttonText = "Processing...";
|
||||
iconClass = "fa-spin fa-refresh";
|
||||
} else if (result.isSuccess) {
|
||||
iconClass = "fa-check fadeout";
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
{result.isSuccess && "Success!"}
|
||||
<button type="submit" disabled={result.isLoading || disabled} {...inputProps}>
|
||||
<i className={`fa fa-fw ${iconClass}`} aria-hidden="true"></i>
|
||||
{result.isLoading
|
||||
? "Processing..."
|
||||
: label
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -46,7 +46,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,
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -20,9 +20,8 @@
|
|||
|
||||
const syncpipe = require("syncpipe");
|
||||
|
||||
module.exports = function useFormSubmit(form, [mutationQuery, result]) {
|
||||
module.exports = function useFormSubmit(form, [mutationQuery, result], { changedOnly = true } = {}) {
|
||||
return [
|
||||
result,
|
||||
function submitForm(e) {
|
||||
e.preventDefault();
|
||||
|
||||
|
@ -31,7 +30,7 @@ module.exports = function useFormSubmit(form, [mutationQuery, result]) {
|
|||
const mutationData = syncpipe(form, [
|
||||
(_) => Object.values(_),
|
||||
(_) => _.map((field) => {
|
||||
if (field.hasChanged()) {
|
||||
if (!changedOnly || field.hasChanged()) {
|
||||
updatedFields.push(field);
|
||||
return [field.name, field.value];
|
||||
} else {
|
||||
|
@ -42,9 +41,8 @@ module.exports = function useFormSubmit(form, [mutationQuery, result]) {
|
|||
(_) => Object.fromEntries(_)
|
||||
]);
|
||||
|
||||
if (updatedFields.length > 0) {
|
||||
return mutationQuery(mutationData);
|
||||
}
|
||||
},
|
||||
result
|
||||
];
|
||||
};
|
|
@ -18,7 +18,11 @@
|
|||
|
||||
"use strict";
|
||||
|
||||
const { updateCacheOnMutation } = require("./lib");
|
||||
const {
|
||||
replaceCacheOnMutation,
|
||||
appendCacheOnMutation,
|
||||
spliceCacheOnMutation
|
||||
} = require("./lib");
|
||||
const base = require("./base");
|
||||
|
||||
const endpoints = (build) => ({
|
||||
|
@ -27,9 +31,10 @@ const endpoints = (build) => ({
|
|||
method: "PATCH",
|
||||
url: `/api/v1/instance`,
|
||||
asForm: true,
|
||||
body: formData
|
||||
body: formData,
|
||||
discardEmpty: true
|
||||
}),
|
||||
...updateCacheOnMutation("instance")
|
||||
...replaceCacheOnMutation("instance")
|
||||
}),
|
||||
mediaCleanup: build.mutation({
|
||||
query: (days) => ({
|
||||
|
@ -39,6 +44,32 @@ const endpoints = (build) => ({
|
|||
remote_cache_days: days
|
||||
}
|
||||
})
|
||||
}),
|
||||
instanceBlocks: build.query({
|
||||
query: () => ({
|
||||
url: `/api/v1/admin/domain_blocks`
|
||||
})
|
||||
}),
|
||||
addInstanceBlock: build.mutation({
|
||||
query: (formData) => ({
|
||||
method: "POST",
|
||||
url: `/api/v1/admin/domain_blocks`,
|
||||
asForm: true,
|
||||
body: formData,
|
||||
discardEmpty: true
|
||||
}),
|
||||
...appendCacheOnMutation("instanceBlocks")
|
||||
}),
|
||||
removeInstanceBlock: build.mutation({
|
||||
query: (id) => ({
|
||||
method: "DELETE",
|
||||
url: `/api/v1/admin/domain_blocks/${id}`,
|
||||
}),
|
||||
...spliceCacheOnMutation("instanceBlocks", {
|
||||
findKey: (draft, newData) => {
|
||||
return draft.findIndex((block) => block.id == newData.id);
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
|
|
|
@ -30,6 +30,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);
|
||||
|
|
|
@ -28,16 +28,37 @@ module.exports = {
|
|||
return res.data;
|
||||
}
|
||||
},
|
||||
updateCacheOnMutation(queryName, arg = undefined) {
|
||||
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];
|
||||
})
|
||||
};
|
||||
|
||||
// https://redux-toolkit.js.org/rtk-query/usage/manual-cache-updates#pessimistic-updates
|
||||
function makeCacheMutation(action) {
|
||||
return function cacheMutation(queryName, { key, findKey, arg } = {}) {
|
||||
return {
|
||||
onQueryStarted: (_, { dispatch, queryFulfilled }) => {
|
||||
queryFulfilled.then(({ data: newData }) => {
|
||||
dispatch(base.util.updateQueryData(queryName, arg, (draft) => {
|
||||
Object.assign(draft, newData);
|
||||
if (findKey != undefined) {
|
||||
key = findKey(draft, newData);
|
||||
}
|
||||
action(draft, newData, key);
|
||||
}));
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
|
@ -18,7 +18,7 @@
|
|||
|
||||
"use strict";
|
||||
|
||||
const { updateCacheOnMutation } = require("./lib");
|
||||
const { replaceCacheOnMutation } = require("./lib");
|
||||
const base = require("./base");
|
||||
|
||||
const endpoints = (build) => ({
|
||||
|
@ -32,9 +32,10 @@ const endpoints = (build) => ({
|
|||
method: "PATCH",
|
||||
url: `/api/v1/accounts/update_credentials`,
|
||||
asForm: true,
|
||||
body: formData
|
||||
body: formData,
|
||||
discardEmpty: true
|
||||
}),
|
||||
...updateCacheOnMutation("verifyCredentials")
|
||||
...replaceCacheOnMutation("verifyCredentials")
|
||||
}),
|
||||
passwordChange: build.mutation({
|
||||
query: (data) => ({
|
||||
|
|
|
@ -218,7 +218,7 @@ section.with-sidebar > div, section.with-sidebar > form {
|
|||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
||||
input, textarea {
|
||||
input, textarea, button {
|
||||
width: 100%;
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
|
@ -228,14 +228,6 @@ section.with-sidebar > div, section.with-sidebar > form {
|
|||
width: initial;
|
||||
}
|
||||
|
||||
input:read-only {
|
||||
border: none;
|
||||
}
|
||||
|
||||
input:invalid {
|
||||
border-color: red;
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
@ -364,7 +356,6 @@ span.form-info {
|
|||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 0.5rem;
|
||||
max-height: 40rem;
|
||||
overflow: auto;
|
||||
|
||||
|
@ -372,10 +363,19 @@ span.form-info {
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -383,15 +383,10 @@ span.form-info {
|
|||
.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 {
|
||||
|
@ -653,3 +648,42 @@ 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 .fa-fw {
|
||||
margin-left: -1.28571429em;
|
||||
}
|
||||
|
||||
.fadeout {
|
||||
animation-name: fadeout;
|
||||
animation-duration: 0.5s;
|
||||
animation-delay: 2s;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeout {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
|
@ -76,7 +76,7 @@ function UserProfileForm({data: profile}) {
|
|||
};
|
||||
|
||||
const allowCustomCSS = Redux.useSelector(state => state.instances.current.configuration.accounts.allow_custom_css);
|
||||
const [result, submitForm] = useFormSubmit(form, query.useUpdateCredentialsMutation());
|
||||
const [submitForm, result] = useFormSubmit(form, query.useUpdateCredentialsMutation());
|
||||
|
||||
return (
|
||||
<form className="user-profile" onSubmit={submitForm}>
|
||||
|
@ -135,7 +135,7 @@ function UserProfileForm({data: profile}) {
|
|||
<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>
|
||||
}
|
||||
<MutationButton text="Save profile info" result={result}/>
|
||||
<MutationButton label="Save profile info" result={result} />
|
||||
</form>
|
||||
);
|
||||
}
|
|
@ -63,7 +63,7 @@ function UserSettingsForm({data: {source}}) {
|
|||
format: useTextInput("source[status_format]", { defaultValue: source.status_format ?? "plain" }),
|
||||
};
|
||||
|
||||
const [result, submitForm] = useFormSubmit(form, query.useUpdateCredentialsMutation());
|
||||
const [submitForm, result] = useFormSubmit(form, query.useUpdateCredentialsMutation());
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -95,7 +95,7 @@ function UserSettingsForm({data: {source}}) {
|
|||
label="Mark my posts as sensitive by default"
|
||||
/>
|
||||
|
||||
<MutationButton text="Save settings" result={result}/>
|
||||
<MutationButton label="Save settings" result={result} />
|
||||
</form>
|
||||
<div>
|
||||
<PasswordChange />
|
||||
|
@ -107,12 +107,14 @@ function UserSettingsForm({data: {source}}) {
|
|||
function PasswordChange() {
|
||||
const form = {
|
||||
oldPassword: useTextInput("old_password"),
|
||||
newPassword: useTextInput("old_password", {validator(val) {
|
||||
newPassword: useTextInput("old_password", {
|
||||
validator(val) {
|
||||
if (val != "" && val == form.oldPassword.value) {
|
||||
return "New password same as old password";
|
||||
}
|
||||
return "";
|
||||
}})
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
const verifyNewPassword = useTextInput("verifyNewPassword", {
|
||||
|
@ -124,7 +126,7 @@ function PasswordChange() {
|
|||
}
|
||||
});
|
||||
|
||||
const [result, submitForm] = useFormSubmit(form, query.usePasswordChangeMutation());
|
||||
const [submitForm, result] = useFormSubmit(form, query.usePasswordChangeMutation());
|
||||
|
||||
return (
|
||||
<form className="change-password" onSubmit={submitForm}>
|
||||
|
@ -132,7 +134,7 @@ function PasswordChange() {
|
|||
<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 text="Change password" result={result}/>
|
||||
<MutationButton label="Change password" result={result} />
|
||||
</form>
|
||||
);
|
||||
}
|
Loading…
Reference in New Issue