mirror of
1
Fork 0

refactor federation/suspend (overview, detail)

This commit is contained in:
f0x 2023-01-10 20:40:35 +00:00
parent 774cb78732
commit 4cbfa77907
22 changed files with 582 additions and 534 deletions

View File

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

View File

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

View File

@ -28,7 +28,7 @@ const { TextInput } = require("../components/form/inputs");
const MutationButton = require("../components/form/mutation-button");
module.exports = function AdminActionPanel() {
const daysField = useTextInput("days", {defaultValue: 30});
const daysField = useTextInput("days", { defaultValue: 30 });
const [mediaCleanup, mediaCleanupResult] = query.useMediaCleanupMutation();
@ -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>
</>
);

View File

@ -34,19 +34,19 @@ const base = "/settings/custom-emoji/local";
module.exports = function EmojiDetailRoute() {
let [_match, params] = useRoute(`${base}/:emojiId`);
if (params?.emojiId == undefined) {
return <Redirect to={base}/>;
return <Redirect to={base} />;
} else {
return (
<div className="emoji-detail">
<Link to={base}><a>&lt; go back</a></Link>
<EmojiDetailData emojiId={params.emojiId}/>
<EmojiDetailData emojiId={params.emojiId} />
</div>
);
}
};
function EmojiDetailData({emojiId}) {
const {currentData: emoji, isLoading, error} = query.useGetEmojiQuery(emojiId);
function EmojiDetailData({ emojiId }) {
const { currentData: emoji, isLoading, error } = query.useGetEmojiQuery(emojiId);
if (error) {
return (
@ -57,20 +57,20 @@ function EmojiDetailData({emojiId}) {
} else if (isLoading) {
return (
<div>
<Loading/>
<Loading />
</div>
);
} else {
return <EmojiDetail emoji={emoji}/>;
return <EmojiDetail emoji={emoji} />;
}
}
function EmojiDetail({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 [categoryState, _resetCategory, { category }] = useComboBoxInput("category", { defaultValue: emoji.category });
const [onFileChange, _resetFile, { image, imageURL, imageInfo }] = useFileInput("image", {
withPreview: true,
@ -78,26 +78,26 @@ function EmojiDetail({emoji}) {
});
function modifyCategory() {
modifyEmoji({id: emoji.id, category: category.trim()});
modifyEmoji({ id: emoji.id, category: category.trim() });
}
function modifyImage() {
modifyEmoji({id: emoji.id, image: image});
modifyEmoji({ id: emoji.id, image: image });
}
React.useEffect(() => {
if (category != emoji.category && !categoryState.open && !isNewCategory && category.trim().length > 0) {
modifyEmoji({id: emoji.id, category: category.trim()});
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]);
return (
<>
<div className="emoji-header">
<img src={emoji.url} alt={emoji.shortcode} title={emoji.shortcode}/>
<img src={emoji.url} alt={emoji.shortcode} title={emoji.shortcode} />
<div>
<h2>{emoji.shortcode}</h2>
<DeleteButton id={emoji.id}/>
<DeleteButton id={emoji.id} />
</div>
</div>
@ -114,7 +114,7 @@ function EmojiDetail({emoji}) {
categoryState={categoryState}
setIsNew={setIsNewCategory}
>
<button style={{visibility: (isNewCategory ? "initial" : "hidden")}} onClick={modifyCategory}>
<button style={{ visibility: (isNewCategory ? "initial" : "hidden") }} onClick={modifyCategory}>
Create
</button>
</CategorySelect>
@ -153,7 +153,7 @@ function EmojiDetail({emoji}) {
);
}
function DeleteButton({id}) {
function DeleteButton({ id }) {
// TODO: confirmation dialog?
const [deleteEmoji, deleteResult] = query.useDeleteEmojiMutation();
@ -163,7 +163,7 @@ function DeleteButton({id}) {
}
if (deleteResult.isSuccess) {
return <Redirect to={base}/>;
return <Redirect to={base} />;
}
return (

View File

@ -50,7 +50,7 @@ module.exports = function NewEmojiForm({ emoji }) {
const [onShortcodeChange, resetShortcode, { shortcode, setShortcode, shortcodeRef }] = useTextInput("shortcode", {
validator: function validateShortcode(code) {
// technically invalid, but hacky fix to prevent validation error on page load
if (shortcode == "") {return "";}
if (shortcode == "") { return ""; }
if (emojiCodes.has(code)) {
return "Shortcode already in use";
@ -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>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -47,19 +47,19 @@ module.exports = function AdminSettings() {
);
};
function AdminSettingsForm({data: instance}) {
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})
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 [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>
);
}

View File

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

View File

@ -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,
@ -172,7 +172,7 @@ function App() {
function Main() {
return (
<Provider store={store}>
<PersistGate loading={<section><Loading/></section>} persistor={persistor}>
<PersistGate loading={<section><Loading /></section>} persistor={persistor}>
<App />
</PersistGate>
</Provider>

View File

@ -21,11 +21,11 @@
const React = require("react");
const prettierBytes = require("prettier-bytes");
module.exports = function useFileInput({name, _Name}, {
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();
@ -40,7 +40,7 @@ module.exports = function useFileInput({name, _Name}, {
if (withPreview) {
setImageURL(URL.createObjectURL(file));
}
let size = prettierBytes(file.size);
if (maxSize && file.size > maxSize) {
size = <span className="error-text">{size}</span>;

View File

@ -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);
}
return mutationQuery(mutationData);
},
result
];
};

View File

@ -18,7 +18,11 @@
"use strict";
const { updateCacheOnMutation } = require("./lib");
const {
replaceCacheOnMutation,
appendCacheOnMutation,
spliceCacheOnMutation
} = require("./lib");
const base = require("./base");
const endpoints = (build) => ({
@ -27,19 +31,46 @@ 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) => ({
method: "POST",
url: `/api/v1/admin/media_cleanup`,
params: {
remote_cache_days: days
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);
}
})
})
});
module.exports = base.injectEndpoints({endpoints});
module.exports = base.injectEndpoints({ endpoints });

View File

@ -24,12 +24,19 @@ const { convertToForm } = require("../api");
function instanceBasedQuery(args, api, extraOptions) {
const state = api.getState();
const {instance, token} = state.oauth;
const { instance, token } = state.oauth;
if (args.baseUrl == undefined) {
args.baseUrl = instance;
}
if (args.discardEmpty) {
if (args.body == undefined || Object.keys(args.body).length == 0) {
return { data: null };
}
delete args.discardEmpty;
}
if (args.asForm) {
delete args.asForm;
args.body = convertToForm(args.body);

View File

@ -28,16 +28,37 @@ module.exports = {
return res.data;
}
},
updateCacheOnMutation(queryName, arg = undefined) {
// https://redux-toolkit.js.org/rtk-query/usage/manual-cache-updates#pessimistic-updates
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}) => {
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);
}));
});
}
};
}
};
};
}

View File

@ -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) => ({
@ -45,4 +46,4 @@ const endpoints = (build) => ({
})
});
module.exports = base.injectEndpoints({endpoints});
module.exports = base.injectEndpoints({ endpoints });

View File

@ -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 {
@ -652,4 +647,43 @@ 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;
}
}

View File

@ -51,7 +51,7 @@ module.exports = function UserProfile() {
);
};
function UserProfileForm({data: profile}) {
function UserProfileForm({ data: profile }) {
/*
User profile update form keys
- bool bot
@ -65,18 +65,18 @@ function UserProfileForm({data: profile}) {
*/
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}),
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 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}>
@ -125,7 +125,7 @@ function UserProfileForm({data: profile}) {
field={form.enableRSS}
label="Enable RSS feed of Public posts"
/>
{ !allowCustomCSS ? null :
{!allowCustomCSS ? null :
<TextArea
field={form.customCSS}
label="Custom CSS"
@ -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>
);
}

View File

@ -48,7 +48,7 @@ module.exports = function UserSettings() {
);
};
function UserSettingsForm({data: {source}}) {
function UserSettingsForm({ data: { source } }) {
/* form keys
- string source[privacy]
- bool source[sensitive]
@ -57,20 +57,20 @@ function UserSettingsForm({data: {source}}) {
*/
const form = {
defaultPrivacy: useTextInput("source[privacy]", {defaultValue: source.privacy ?? "unlisted"}),
isSensitive: useBoolInput("source[sensitive]", {defaultValue: source.sensitive}),
language: useTextInput("source[language]", {defaultValue: source.language ?? "EN"}),
format: useTextInput("source[status_format]", {defaultValue: source.status_format ?? "plain"}),
defaultPrivacy: useTextInput("source[privacy]", { defaultValue: source.privacy ?? "unlisted" }),
isSensitive: useBoolInput("source[sensitive]", { defaultValue: source.sensitive }),
language: useTextInput("source[language]", { defaultValue: source.language ?? "EN" }),
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 (
<>
<form className="user-settings" onSubmit={submitForm}>
<h1>Post settings</h1>
<Select field={form.language} label="Default post language" options={
<Languages/>
<Languages />
}>
</Select>
<Select field={form.defaultPrivacy} label="Default post privacy" options={
@ -95,10 +95,10 @@ 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/>
<PasswordChange />
</div>
</>
);
@ -107,12 +107,14 @@ function UserSettingsForm({data: {source}}) {
function PasswordChange() {
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";
newPassword: useTextInput("old_password", {
validator(val) {
if (val != "" && val == form.oldPassword.value) {
return "New password same as old password";
}
return "";
}
return "";
}})
})
};
const verifyNewPassword = useTextInput("verifyNewPassword", {
@ -124,15 +126,15 @@ function PasswordChange() {
}
});
const [result, submitForm] = useFormSubmit(form, query.usePasswordChangeMutation());
const [submitForm, result] = useFormSubmit(form, query.usePasswordChangeMutation());
return (
<form className="change-password" onSubmit={submitForm}>
<h1>Change password</h1>
<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}/>
<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>
);
}