[frogend] Settings refactor (#1318)

* yakshave new form field structure

* fully refactor user profile settings form

* use rtk query api for profile settings

* refactor user post settings

* refactor password change form

* refactor admin settings

* FormWithData structure for user forms

* admin actions refactor

* whitespace

* fix user settings data prop

* remove superfluous logging

* cleanup old code

* refactor federation/suspend (overview, detail)

* mostly abstracted (emoji) checkbox list

* refactor parse-from-toot

* refactor custom-emoji, progress on federation bulk

* loading icon styling to prevent big spinny

* refactor federation import-export interface

* cleanup old files

* [chore] Update/add license headers for 2023

* redux fixes

* text-field exports

* appease the linter

* refactor authentication with RTK Query

* fix login/logout state transition weirdness

* fixes/cleanup

* small linter-related fixes

* add eslint license header check, fix existing files

* remove old code, clarify comment

* clarify suspend on subdomains

* collapse if/else

* fa-fw width info comment
This commit is contained in:
f0x52
2023-01-18 14:45:14 +01:00
committed by GitHub
parent 974ec80a20
commit 9b139b6320
69 changed files with 3129 additions and 2663 deletions

View File

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

View File

@@ -1,19 +1,19 @@
/*
GoToSocial
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
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 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.
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/>.
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";
@@ -36,13 +36,15 @@ function useEmojiByCategory(emoji) {
), [emoji]);
}
function CategorySelect({value, categoryState, setIsNew=() => {}, children}) {
function CategorySelect({ field, children }) {
const { value, setIsNew } = field;
const {
data: emoji = [],
isLoading,
isSuccess,
error
} = query.useGetAllEmojiQuery({filter: "domain:local"});
} = query.useGetAllEmojiQuery({ filter: "domain:local" });
const emojiByCategory = useEmojiByCategory(emoji);
@@ -52,7 +54,7 @@ function CategorySelect({value, categoryState, setIsNew=() => {}, children}) {
const categoryItems = React.useMemo(() => {
return syncpipe(emojiByCategory, [
(_) => Object.keys(_), // just emoji category names
(_) => matchSorter(_, value, {threshold: matchSorter.rankings.NO_MATCH}), // sorted by complex algorithm
(_) => matchSorter(_, value, { threshold: matchSorter.rankings.NO_MATCH }), // sorted by complex algorithm
(_) => _.map((categoryName) => [ // map to input value, and selectable element with icon
categoryName,
<>
@@ -67,24 +69,24 @@ 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) {
return <input type="text" value="Loading categories..." disabled={true}/>;
return <input type="text" value="Loading categories..." disabled={true} />;
}
return (
<ComboBox
state={categoryState}
field={field}
items={categoryItems}
label="Category"
placeHolder="e.g., reactions"
placeholder="e.g., reactions"
children={children}
/>
);

View File

@@ -19,155 +19,128 @@
"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";
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}/>
<FormWithData dataQuery={query.useGetEmojiQuery} queryArg={params.emojiId} DataForm={EmojiDetailForm} />
</div>
);
}
};
function EmojiDetailData({emojiId}) {
const {currentData: emoji, isLoading, error} = query.useGetEmojiQuery(emojiId);
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 // TODO: get from instance api
})
};
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", {
withPreview: true,
maxSize: 50 * 1024
});
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();
}
}, [isNewCategory, category, categoryState.open, emoji.category, emoji.id, modifyEmoji]);
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [form.category.hasChanged(), form.category.isNew, form.category.state.open]);
const [deleteEmoji, deleteResult] = query.useDeleteEmojiMutation();
if (deleteResult.isSuccess) {
return <Redirect to={base} />;
}
return (
<>
<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}/>
<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"
accept="image/png,image/gif"
onChange={onFileChange}
/>
</div>
<FileInput
field={form.image}
label="Image"
accept="image/png,image/gif"
/>
<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&apos;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>
);
}

View File

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

View File

@@ -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&apos;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"
accept="image/png,image/gif"
onChange={onFileChange}
/>
</div>
<div className="form-field text">
<label htmlFor="shortcode">
Shortcode, must be unique among the instance's local emoji
</label>
<input
type="text"
id="shortcode"
name="Shortcode"
ref={shortcodeRef}
onChange={onShortcodeChange}
value={shortcode}
/>
</div>
<CategorySelect
value={category}
categoryState={categoryState}
<form onSubmit={submitForm} className="form-flex">
<FileInput
field={image}
accept="image/png,image/gif"
/>
<MutateButton text="Upload emoji" result={result} />
<TextInput
field={shortcode}
label="Shortcode, must be unique among the instance's local emoji"
/>
<CategorySelect
field={category}
/>
<MutationButton label="Upload emoji" result={result} />
</form>
</div>
);

View File

@@ -1,25 +1,25 @@
/*
GoToSocial
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
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 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.
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/>.
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} = require("wouter");
const { Link } = require("wouter");
const NewEmojiForm = require("./new-emoji");
@@ -27,33 +27,31 @@ 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,
error
} = query.useGetAllEmojiQuery({filter: "domain:local"});
} = query.useGetAllEmojiQuery({ filter: "domain:local" });
return (
<>
<h1>Custom Emoji (local)</h1>
{error &&
{error &&
<div className="error accent">{error}</div>
}
{isLoading
? <Loading/>
? <Loading />
: <>
<EmojiList emoji={emoji}/>
<NewEmojiForm emoji={emoji}/>
<EmojiList emoji={emoji} baseUrl={baseUrl} />
<NewEmojiForm emoji={emoji} />
</>
}
</>
);
};
function EmojiList({emoji}) {
function EmojiList({ emoji, baseUrl }) {
const emojiByCategory = useEmojiByCategory(emoji);
return (
@@ -62,24 +60,23 @@ 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}:`}/>
<img src={e.url} alt={e.shortcode} title={`:${e.shortcode}:`} />
</a>
</Link>
);

View File

@@ -0,0 +1,61 @@
/*
GoToSocial
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
"use strict";
const React = require("react");
const query = require("../../../lib/query");
const { useTextInput } = require("../../../lib/form");
const shortcodeRegex = /^[a-z0-9_]+$/;
module.exports = function useShortcode() {
const {
data: emoji = []
} = query.useGetAllEmojiQuery({ filter: "domain:local" });
const emojiCodes = React.useMemo(() => {
return new Set(emoji.map((e) => e.shortcode));
}, [emoji]);
return useTextInput("shortcode", {
validator: function validateShortcode(code) {
// technically invalid, but hacky fix to prevent validation error on page load
if (code == "") { return ""; }
if (emojiCodes.has(code)) {
return "Shortcode already in use";
}
if (code.length < 2 || code.length > 30) {
return "Shortcode must be between 2 and 30 characters";
}
if (code.toLowerCase() != code) {
return "Shortcode must be lowercase";
}
if (!shortcodeRegex.test(code)) {
return "Shortcode must only contain lowercase letters, numbers, and underscores";
}
return "";
}
});
};

View File

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

View File

@@ -18,57 +18,35 @@
"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();
searchStatus(url);
if (url.trim().length != 0) {
searchStatus(url);
}
}
return (
@@ -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"/>
].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;
}
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} />;
}
if (data.list.length == 0) {
return <b>This {data.type == "statuses" ? "toot" : "account"} doesn't use any custom emoji</b>;
}
return (
<CopyEmojiForm
localEmojiCodes={localEmojiCodes}
type={data.type}
domain={data.domain}
emojiList={data.list}
/>
);
}
function updateEmojiState(emojiState, checked) {
/* 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(_)
]);
}
function CopyEmojiForm({ localEmojiCodes, type, emojiList }) {
const form = {
selectedEmoji: useCheckListInput("selectedEmoji", {
entries: emojiList,
uniqueKey: "shortcode"
}),
category: useComboBoxInput("category")
};
function CopyEmojiForm({ localEmojiCodes, type, domain, emojiList }) {
const [patchRemoteEmojis, patchResult] = query.usePatchRemoteEmojisMutation();
const [err, setError] = React.useState();
const [formSubmit, result] = useFormSubmit(form, query.usePatchRemoteEmojisMutation(), { changedOnly: false });
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;
}
let values = Object.values(emojiState);
/* one or more boxes are checked */
let some = values.some((v) => v.checked);
let all = false;
if (some) {
/* there's not at least one unchecked box */
all = !values.some((v) => v.checked == false);
}
setSomeSelected(some);
if (some && !all) {
setToggleAllState(2);
toggleAllRef.current.indeterminate = true;
} else {
setToggleAllState(all ? 1 : 0);
toggleAllRef.current.indeterminate = false;
}
}, [emojiState, toggleAllRef]);
function updateEmoji(shortcode, value) {
setEmojiState({
...emojiState,
[shortcode]: {
...emojiState[shortcode],
...value
}
});
}
function toggleAll(e) {
let selectAll = e.target.checked;
if (toggleAllState == 2) { // indeterminate
selectAll = false;
}
setEmojiState(updateEmojiState(emojiState, selectAll));
setToggleAllState(selectAll);
}
function submit(action) {
Promise.try(() => {
setError(null);
const selectedShortcodes = syncpipe(emojiState, [
(_) => Object.entries(_),
(_) => _.filter(([_shortcode, entry]) => entry.checked),
(_) => _.map(([shortcode, entry]) => {
if (action == "copy" && !entry.valid) {
throw `One or more selected emoji have non-unique shortcodes (${shortcode}), unselect them or pick a different local shortcode`;
}
return {
shortcode,
localShortcode: entry.shortcode
};
})
]);
return patchRemoteEmojis({
action,
domain,
list: selectedShortcodes,
category
}).unwrap();
}).then(() => {
setEmojiState(makeEmojiState(emojiList, false));
resetCategory();
}).catch((e) => {
if (Array.isArray(e)) {
setError(e.map(([shortcode, msg]) => (
<div key={shortcode}>
{shortcode}: <span style={{ fontWeight: "initial" }}>{msg}</span>
</div>
)));
} else {
setError(e);
}
});
}
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}
localEmojiCodes={localEmojiCodes}
updateEmoji={(value) => updateEmoji(emoji.shortcode, value)}
checked={emojiState[emoji.shortcode].checked}
/>
))}
</div>
<form onSubmit={formSubmit}>
<CheckList
field={form.selectedEmoji}
Component={EmojiEntry}
localEmojiCodes={localEmojiCodes}
/>
<CategorySelect
value={category}
categoryState={categoryState}
/>
<CategorySelect
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>
</div>
{err && <div className="error">
{err}
</div>}
{patchResult.isSuccess && <div>
Action applied to {patchResult.data.length} emoji
</div>}
<div className="action-buttons row">
<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>
{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>
</>
);
}

View File

@@ -1,394 +0,0 @@
/*
GoToSocial
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
"use strict";
const Promise = require("bluebird");
const React = require("react");
const Redux = require("react-redux");
const {Switch, Route, Link, Redirect, useRoute, useLocation} = require("wouter");
const fileDownload = require("js-file-download");
const { formFields } = require("../components/form-fields");
const api = require("../lib/api");
const adminActions = require("../redux/reducers/admin").actions;
const submit = require("../lib/submit");
const BackButton = require("../components/back-button");
const Loading = require("../components/loading");
const { matchSorter } = require("match-sorter");
const base = "/settings/admin/federation";
// const {
// TextInput,
// TextArea,
// File
// } = require("../components/form-fields").formFields(adminActions.setAdminSettingsVal, (state) => state.instances.adminSettings);
module.exports = function AdminSettings() {
const dispatch = Redux.useDispatch();
// const instance = Redux.useSelector(state => state.instances.adminSettings);
const loadedBlockedInstances = Redux.useSelector(state => state.admin.loadedBlockedInstances);
React.useEffect(() => {
if (!loadedBlockedInstances ) {
Promise.try(() => {
return dispatch(api.admin.fetchDomainBlocks());
});
}
}, [dispatch, loadedBlockedInstances]);
if (!loadedBlockedInstances) {
return (
<div>
<h1>Federation</h1>
<div>
<Loading/>
</div>
</div>
);
}
return (
<Switch>
<Route path={`${base}/:domain`}>
<InstancePageWrapped />
</Route>
<InstanceOverview />
</Switch>
);
};
function InstanceOverview() {
const [filter, setFilter] = React.useState("");
const blockedInstances = Redux.useSelector(state => state.admin.blockedInstances);
const [_location, setLocation] = useLocation();
const filteredInstances = React.useMemo(() => {
return matchSorter(Object.values(blockedInstances), filter, {keys: ["domain"]});
}, [blockedInstances, filter]);
function filterFormSubmit(e) {
e.preventDefault();
setLocation(`${base}/${filter}`);
}
return (
<>
<h1>Federation</h1>
Here you can see an overview of blocked instances.
<div className="instance-list">
<h2>Blocked instances</h2>
<form action={`${base}/view`} className="filter" role="search" onSubmit={filterFormSubmit}>
<input name="domain" value={filter} onChange={(e) => setFilter(e.target.value)}/>
<Link to={`${base}/${filter}`}><a className="button">Add block</a></Link>
</form>
<div className="list">
{filteredInstances.map((entry) => {
return (
<Link key={entry.domain} to={`${base}/${entry.domain}`}>
<a className="entry nounderline">
<span id="domain">
{entry.domain}
</span>
<span id="date">
{new Date(entry.created_at).toLocaleString()}
</span>
</a>
</Link>
);
})}
</div>
</div>
<BulkBlocking/>
</>
);
}
const Bulk = formFields(adminActions.updateBulkBlockVal, (state) => state.admin.bulkBlock);
function BulkBlocking() {
const dispatch = Redux.useDispatch();
const {bulkBlock, blockedInstances} = Redux.useSelector(state => state.admin);
const [errorMsg, setError] = React.useState("");
const [statusMsg, setStatus] = React.useState("");
function importBlocks() {
setStatus("Processing");
setError("");
return Promise.try(() => {
return dispatch(api.admin.bulkDomainBlock());
}).then(({success, invalidDomains}) => {
return Promise.try(() => {
return resetBulk();
}).then(() => {
dispatch(adminActions.updateBulkBlockVal(["list", invalidDomains.join("\n")]));
let stat = "";
if (success == 0) {
return setError("No valid domains in import");
} else if (success == 1) {
stat = "Imported 1 domain";
} else {
stat = `Imported ${success} domains`;
}
if (invalidDomains.length > 0) {
if (invalidDomains.length == 1) {
stat += ", input contained 1 invalid domain.";
} else {
stat += `, input contained ${invalidDomains.length} invalid domains.`;
}
} else {
stat += "!";
}
setStatus(stat);
});
}).catch((e) => {
console.error(e);
setError(e.message);
setStatus("");
});
}
function exportBlocks() {
return Promise.try(() => {
setStatus("Exporting");
setError("");
let asJSON = bulkBlock.exportType.startsWith("json");
let _asCSV = bulkBlock.exportType.startsWith("csv");
let exportList = Object.values(blockedInstances).map((entry) => {
if (asJSON) {
return {
domain: entry.domain,
public_comment: entry.public_comment
};
} else {
return entry.domain;
}
});
if (bulkBlock.exportType == "json") {
return dispatch(adminActions.updateBulkBlockVal(["list", JSON.stringify(exportList)]));
} else if (bulkBlock.exportType == "json-download") {
return fileDownload(JSON.stringify(exportList), "block-export.json");
} else if (bulkBlock.exportType == "plain") {
return dispatch(adminActions.updateBulkBlockVal(["list", exportList.join("\n")]));
}
}).then(() => {
setStatus("Exported!");
}).catch((e) => {
setError(e.message);
setStatus("");
});
}
function resetBulk(e) {
if (e != undefined) {
e.preventDefault();
}
return dispatch(adminActions.resetBulkBlockVal());
}
function disableInfoFields(props={}) {
if (bulkBlock.list[0] == "[") {
return {
...props,
disabled: true,
placeHolder: "Domain list is a JSON import, input disabled"
};
} else {
return props;
}
}
return (
<div className="bulk">
<h2>Import / Export <a onClick={resetBulk}>reset</a></h2>
<Bulk.TextArea
id="list"
name="Domains, one per line"
placeHolder={`google.com\nfacebook.com`}
/>
<Bulk.TextArea
id="public_comment"
name="Public comment"
inputProps={disableInfoFields({rows: 3})}
/>
<Bulk.TextArea
id="private_comment"
name="Private comment"
inputProps={disableInfoFields({rows: 3})}
/>
<Bulk.Checkbox
id="obfuscate"
name="Obfuscate domains? "
inputProps={disableInfoFields()}
/>
<div className="hidden">
<Bulk.File
id="json"
fileType="application/json"
withPreview={false}
/>
</div>
<div className="messagebutton">
<div>
<button type="submit" onClick={importBlocks}>Import</button>
</div>
<div>
<button type="submit" onClick={exportBlocks}>Export</button>
<Bulk.Select id="exportType" name="Export type" options={
<>
<option value="plain">One per line in text field</option>
<option value="json">JSON in text field</option>
<option value="json-download">JSON file download</option>
<option disabled value="csv">CSV in text field (glitch-soc)</option>
<option disabled value="csv-download">CSV file download (glitch-soc)</option>
</>
}/>
</div>
<br/>
<div>
{errorMsg.length > 0 &&
<div className="error accent">{errorMsg}</div>
}
{statusMsg.length > 0 &&
<div className="accent">{statusMsg}</div>
}
</div>
</div>
</div>
);
}
function InstancePageWrapped() {
/* We wrap the component to generate formFields with a setter depending on the domain
if formFields() is used inside the same component that is re-rendered with their state,
inputs get re-created on every change, causing them to lose focus, and bad performance
*/
let [_match, {domain}] = useRoute(`${base}/:domain`);
if (domain == "view") { // from form field submission
let realDomain = (new URL(document.location)).searchParams.get("domain");
if (realDomain == undefined) {
return <Redirect to={base}/>;
} else {
domain = realDomain;
}
}
function alterDomain([key, val]) {
return adminActions.updateDomainBlockVal([domain, key, val]);
}
const fields = formFields(alterDomain, (state) => state.admin.newInstanceBlocks[domain]);
return <InstancePage domain={domain} Form={fields} />;
}
function InstancePage({domain, Form}) {
const dispatch = Redux.useDispatch();
const entry = Redux.useSelector(state => state.admin.newInstanceBlocks[domain]);
const [_location, setLocation] = useLocation();
React.useEffect(() => {
if (entry == undefined) {
dispatch(api.admin.getEditableDomainBlock(domain));
}
}, [dispatch, domain, entry]);
const [errorMsg, setError] = React.useState("");
const [statusMsg, setStatus] = React.useState("");
if (entry == undefined) {
return <Loading/>;
}
const updateBlock = submit(
() => dispatch(api.admin.updateDomainBlock(domain)),
{setStatus, setError}
);
const removeBlock = submit(
() => dispatch(api.admin.removeDomainBlock(domain)),
{setStatus, setError, startStatus: "Removing", successStatus: "Removed!", onSuccess: () => {
setLocation(base);
}}
);
return (
<div>
<h1><BackButton to={base}/> Federation settings for: {domain}</h1>
{entry.new
? "No stored block yet, you can add one below:"
: <b className="error">Editing domain blocks is not implemented yet, <a href="https://github.com/superseriousbusiness/gotosocial/issues/1198" target="_blank" rel="noopener noreferrer">check here for progress</a>.</b>
}
<Form.TextArea
id="public_comment"
name="Public comment"
inputProps={{
disabled: !entry.new
}}
/>
<Form.TextArea
id="private_comment"
name="Private comment"
inputProps={{
disabled: !entry.new
}}
/>
<Form.Checkbox
id="obfuscate"
name="Obfuscate domain? "
inputProps={{
disabled: !entry.new
}}
/>
<div className="messagebutton">
{entry.new
? <button type="submit" onClick={updateBlock}>{entry.new ? "Add block" : "Save block"}</button>
: <button className="danger" onClick={removeBlock}>Remove block</button>
}
{errorMsg.length > 0 &&
<div className="error accent">{errorMsg}</div>
}
{statusMsg.length > 0 &&
<div className="accent">{statusMsg}</div>
}
</div>
</div>
);
}

View File

@@ -0,0 +1,146 @@
/*
GoToSocial
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
"use strict";
const React = require("react");
const { useRoute, Redirect } = require("wouter");
const query = require("../../lib/query");
const { useTextInput, useBoolInput } = require("../../lib/form");
const useFormSubmit = require("../../lib/form/submit");
const { TextInput, Checkbox, TextArea } = require("../../components/form/inputs");
const Loading = require("../../components/loading");
const BackButton = require("../../components/back-button");
const MutationButton = require("../../components/form/mutation-button");
module.exports = function InstanceDetail({ baseUrl }) {
const { data: blockedInstances = {}, isLoading } = query.useInstanceBlocksQuery();
let [_match, { domain }] = useRoute(`${baseUrl}/:domain`);
if (domain == "view") { // from form field submission
domain = (new URL(document.location)).searchParams.get("domain");
}
const existingBlock = React.useMemo(() => {
return blockedInstances[domain];
}, [blockedInstances, domain]);
if (domain == undefined) {
return <Redirect to={baseUrl} />;
}
let infoContent = null;
if (isLoading) {
infoContent = <Loading />;
} else if (existingBlock == undefined) {
infoContent = <span>No stored block yet, you can add one below:</span>;
} else {
infoContent = (
<div className="info">
<i className="fa fa-fw fa-exclamation-triangle" aria-hidden="true"></i>
<b>Editing domain blocks isn't implemented yet, <a href="https://github.com/superseriousbusiness/gotosocial/issues/1198" target="_blank" rel="noopener noreferrer">check here for progress</a></b>
</div>
);
}
return (
<div>
<h1><BackButton to={baseUrl} /> Federation settings for: {domain}</h1>
{infoContent}
<DomainBlockForm defaultDomain={domain} block={existingBlock} />
</div>
);
};
function DomainBlockForm({ defaultDomain, block = {} }) {
const isExistingBlock = block.domain != undefined;
const disabledForm = isExistingBlock
? {
disabled: true,
title: "Domain suspensions currently cannot be edited."
}
: {};
const form = {
domain: useTextInput("domain", { defaultValue: block.domain ?? defaultDomain }),
obfuscate: useBoolInput("obfuscate", { defaultValue: block.obfuscate }),
commentPrivate: useTextInput("private_comment", { defaultValue: block.private_comment }),
commentPublic: useTextInput("public_comment", { defaultValue: block.public_comment })
};
const [submitForm, addResult] = useFormSubmit(form, query.useAddInstanceBlockMutation(), { changedOnly: false });
const [removeBlock, removeResult] = query.useRemoveInstanceBlockMutation({ fixedCacheKey: block.id });
return (
<form onSubmit={submitForm}>
<TextInput
field={form.domain}
label="Domain"
placeholder="example.com"
{...disabledForm}
/>
<Checkbox
field={form.obfuscate}
label="Obfuscate domain in public lists"
{...disabledForm}
/>
<TextArea
field={form.commentPrivate}
label="Private comment"
rows={3}
{...disabledForm}
/>
<TextArea
field={form.commentPublic}
label="Public comment"
rows={3}
{...disabledForm}
/>
<MutationButton
label="Suspend"
result={addResult}
{...disabledForm}
/>
{
isExistingBlock &&
<MutationButton
type="button"
onClick={() => removeBlock(block.id)}
label="Remove"
result={removeResult}
className="button danger"
/>
}
</form>
);
}

View File

@@ -0,0 +1,307 @@
/*
GoToSocial
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
"use strict";
const React = require("react");
const { Switch, Route, Redirect, useLocation } = require("wouter");
const query = require("../../lib/query");
const {
useTextInput,
useBoolInput,
useRadioInput,
useCheckListInput
} = require("../../lib/form");
const useFormSubmit = require("../../lib/form/submit");
const {
TextInput,
TextArea,
Checkbox,
Select,
RadioGroup
} = require("../../components/form/inputs");
const CheckList = require("../../components/check-list");
const MutationButton = require("../../components/form/mutation-button");
const isValidDomain = require("is-valid-domain");
const FormWithData = require("../../lib/form/form-with-data");
const { Error } = require("../../components/error");
const baseUrl = "/settings/admin/federation/import-export";
module.exports = function ImportExport() {
const [updateFromFile, setUpdateFromFile] = React.useState(false);
const form = {
domains: useTextInput("domains"),
exportType: useTextInput("exportType", { defaultValue: "plain", dontReset: true })
};
const [submitParse, parseResult] = useFormSubmit(form, query.useProcessDomainListMutation());
const [submitExport, exportResult] = useFormSubmit(form, query.useExportDomainListMutation());
function fileChanged(e) {
const reader = new FileReader();
reader.onload = function (read) {
form.domains.setter(read.target.result);
setUpdateFromFile(true);
};
reader.readAsText(e.target.files[0]);
}
React.useEffect(() => {
if (exportResult.isSuccess) {
form.domains.setter(exportResult.data);
}
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [exportResult]);
const [_location, setLocation] = useLocation();
if (updateFromFile) {
setUpdateFromFile(false);
submitParse();
}
return (
<Switch>
<Route path={`${baseUrl}/list`}>
{!parseResult.isSuccess && <Redirect to={baseUrl} />}
<h1>
<span className="button" onClick={() => {
parseResult.reset();
setLocation(baseUrl);
}}>
&lt; back
</span> Confirm import:
</h1>
<FormWithData
dataQuery={query.useInstanceBlocksQuery}
DataForm={ImportList}
list={parseResult.data}
/>
</Route>
<Route>
{parseResult.isSuccess && <Redirect to={`${baseUrl}/list`} />}
<h2>Import / Export suspended domains</h2>
<div>
<form onSubmit={submitParse}>
<TextArea
field={form.domains}
label="Domains, one per line (plaintext) or JSON"
placeholder={`google.com\nfacebook.com`}
rows={8}
/>
<div className="row">
<MutationButton label="Import" result={parseResult} showError={false} />
<button type="button" className="with-padding">
<label>
Import file
<input className="hidden" type="file" onChange={fileChanged} accept="application/json,text/plain" />
</label>
</button>
</div>
</form>
<form onSubmit={submitExport}>
<div className="row">
<MutationButton name="export" label="Export" result={exportResult} showError={false} />
<MutationButton name="export-file" label="Export file" result={exportResult} showError={false} />
<Select
field={form.exportType}
options={<>
<option value="plain">Text</option>
<option value="json">JSON</option>
</>}
/>
</div>
</form>
{parseResult.error && <Error error={parseResult.error} />}
{exportResult.error && <Error error={exportResult.error} />}
</div>
</Route>
</Switch>
);
};
function ImportList({ list, data: blockedInstances }) {
const hasComment = React.useMemo(() => {
let hasPublic = false;
let hasPrivate = false;
list.some((entry) => {
if (entry.public_comment?.length > 0) {
hasPublic = true;
}
if (entry.private_comment?.length > 0) {
hasPrivate = true;
}
return hasPublic && hasPrivate;
});
if (hasPublic && hasPrivate) {
return { both: true };
} else if (hasPublic) {
return { type: "public_comment" };
} else if (hasPrivate) {
return { type: "private_comment" };
} else {
return {};
}
}, [list]);
const showComment = useTextInput("showComment", { defaultValue: hasComment.type ?? "public_comment" });
let commentName = "";
if (showComment.value == "public_comment") { commentName = "Public comment"; }
if (showComment.value == "private_comment") { commentName = "Private comment"; }
const form = {
domains: useCheckListInput("domains", {
entries: list,
uniqueKey: "domain"
}),
obfuscate: useBoolInput("obfuscate"),
privateComment: useTextInput("private_comment", {
defaultValue: `Imported on ${new Date().toLocaleString()}`
}),
privateCommentBehavior: useRadioInput("private_comment_behavior", {
defaultValue: "append",
options: {
append: "Append to",
replace: "Replace"
}
}),
publicComment: useTextInput("public_comment"),
publicCommentBehavior: useRadioInput("public_comment_behavior", {
defaultValue: "append",
options: {
append: "Append to",
replace: "Replace"
}
}),
};
const [importDomains, importResult] = useFormSubmit(form, query.useImportDomainListMutation(), { changedOnly: false });
return (
<>
<form onSubmit={importDomains} className="suspend-import-list">
<span>{list.length} domain{list.length != 1 ? "s" : ""} in this list</span>
{hasComment.both &&
<Select field={showComment} options={
<>
<option value="public_comment">Show public comments</option>
<option value="private_comment">Show private comments</option>
</>
} />
}
<CheckList
field={form.domains}
Component={DomainEntry}
header={
<>
<b>Domain</b>
<b></b>
<b>{commentName}</b>
</>
}
blockedInstances={blockedInstances}
commentType={showComment.value}
/>
<TextArea
field={form.privateComment}
label="Private comment"
rows={3}
/>
<RadioGroup
field={form.privateCommentBehavior}
label="imported private comment"
/>
<TextArea
field={form.publicComment}
label="Public comment"
rows={3}
/>
<RadioGroup
field={form.publicCommentBehavior}
label="imported public comment"
/>
<Checkbox
field={form.obfuscate}
label="Obfuscate domains in public lists"
/>
<MutationButton label="Import" result={importResult} />
</form>
</>
);
}
function DomainEntry({ entry, onChange, blockedInstances, commentType }) {
const domainField = useTextInput("domain", {
defaultValue: entry.domain,
validator: (value) => {
return (entry.checked && !isValidDomain(value, { wildcard: true, allowUnicode: true }))
? "Invalid domain"
: "";
}
});
React.useEffect(() => {
onChange({ valid: domainField.valid });
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [domainField.valid]);
let icon = null;
if (blockedInstances[domainField.value] != undefined) {
icon = (
<>
<i className="fa fa-history already-blocked" aria-hidden="true" title="Domain block already exists"></i>
<span className="sr-only">Domain block already exists.</span>
</>
);
}
return (
<>
<TextInput
field={domainField}
onChange={(e) => {
domainField.onChange(e);
onChange({ domain: e.target.value, checked: true });
}}
/>
<span id="icon">{icon}</span>
<p>{entry[commentType]}</p>
</>
);
}

View File

@@ -0,0 +1,44 @@
/*
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 } = require("wouter");
const baseUrl = `/settings/admin/federation`;
const InstanceOverview = require("./overview");
const InstanceDetail = require("./detail");
const InstanceImportExport = require("./import-export");
module.exports = function Federation({ }) {
return (
<Switch>
<Route path={`${baseUrl}/import-export/:list?`}>
<InstanceImportExport />
</Route>
<Route path={`${baseUrl}/:domain`}>
<InstanceDetail baseUrl={baseUrl} />
</Route>
<InstanceOverview baseUrl={baseUrl} />
</Switch>
);
};

View File

@@ -0,0 +1,100 @@
/*
GoToSocial
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
"use strict";
const React = require("react");
const { Link, useLocation } = require("wouter");
const { matchSorter } = require("match-sorter");
const { useTextInput } = require("../../lib/form");
const { TextInput } = require("../../components/form/inputs");
const query = require("../../lib/query");
const Loading = require("../../components/loading");
module.exports = function InstanceOverview({ baseUrl }) {
const { data: blockedInstances = [], isLoading } = query.useInstanceBlocksQuery();
const [_location, setLocation] = useLocation();
const filterField = useTextInput("filter");
const filter = filterField.value;
const blockedInstancesList = React.useMemo(() => {
return Object.values(blockedInstances);
}, [blockedInstances]);
const filteredInstances = React.useMemo(() => {
return matchSorter(blockedInstancesList, filter, { keys: ["domain"] });
}, [blockedInstancesList, filter]);
let filtered = blockedInstancesList.length - filteredInstances.length;
function filterFormSubmit(e) {
e.preventDefault();
setLocation(`${baseUrl}/${filter}`);
}
if (isLoading) {
return <Loading />;
}
return (
<>
<h1>Federation</h1>
<div className="instance-list">
<h2>Suspended instances</h2>
<p>
Suspending a domain blocks all current and future accounts on that instance. Stored content will be removed,
and no more data is sent to the remote server.<br />
This extends to all subdomains as well, so blocking 'example.com' also includes 'social.example.com'.
</p>
<form className="filter" role="search" onSubmit={filterFormSubmit}>
<TextInput field={filterField} placeholder="example.com" label="Search or add domain suspension" />
<Link to={`${baseUrl}/${filter}`}><a className="button">Suspend</a></Link>
</form>
<div>
<span>
{blockedInstancesList.length} blocked instance{blockedInstancesList.length != 1 ? "s" : ""} {filtered > 0 && `(${filtered} filtered by search)`}
</span>
<div className="list scrolling">
{filteredInstances.map((entry) => {
return (
<Link key={entry.domain} to={`${baseUrl}/${entry.domain}`}>
<a className="entry nounderline">
<span id="domain">
{entry.domain}
</span>
<span id="date">
{new Date(entry.created_at).toLocaleString()}
</span>
</a>
</Link>
);
})}
</div>
</div>
</div>
<Link to={`${baseUrl}/import-export`}><a>Or use the bulk import/export interface</a></Link>
</>
);
};

View File

@@ -19,88 +19,105 @@
"use strict";
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>
);
};
}