mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2025-11-18 14:39:14 -06:00
[chore] Refactor settings panel routing (and other fixes) (#2864)
This commit is contained in:
parent
62788aa116
commit
7a1e639483
55 changed files with 1788 additions and 1445 deletions
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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/>.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { useInstanceKeysExpireMutation } from "../../../../lib/query";
|
||||
import { TextInput } from "../../../../components/form/inputs";
|
||||
import MutationButton from "../../../../components/form/mutation-button";
|
||||
import { useTextInput } from "../../../../lib/form";
|
||||
|
||||
export default function ExpireRemote({}) {
|
||||
const domainField = useTextInput("domain");
|
||||
|
||||
const [expire, expireResult] = useInstanceKeysExpireMutation();
|
||||
|
||||
function submitExpire(e) {
|
||||
e.preventDefault();
|
||||
expire(domainField.value);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submitExpire}>
|
||||
<h2>Expire remote instance keys</h2>
|
||||
<p>
|
||||
Mark all public keys from the given remote instance as expired.<br/><br/>
|
||||
This is useful in cases where the remote domain has had to rotate their keys for whatever
|
||||
reason (security issue, data leak, routine safety procedure, etc), and your instance can no
|
||||
longer communicate with theirs properly using cached keys. A key marked as expired in this way
|
||||
will be lazily refetched next time a request is made to your instance signed by the owner of that
|
||||
key.
|
||||
</p>
|
||||
<TextInput
|
||||
field={domainField}
|
||||
label="Domain"
|
||||
type="string"
|
||||
placeholder="example.org"
|
||||
/>
|
||||
<MutationButton
|
||||
disabled={!domainField.value}
|
||||
label="Expire keys"
|
||||
result={expireResult}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
30
web/source/settings/views/admin/actions/keys/index.tsx
Normal file
30
web/source/settings/views/admin/actions/keys/index.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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/>.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import ExpireRemote from "./expireremote";
|
||||
|
||||
export default function Keys() {
|
||||
return (
|
||||
<>
|
||||
<h1>Key Actions</h1>
|
||||
<ExpireRemote />
|
||||
</>
|
||||
);
|
||||
}
|
||||
59
web/source/settings/views/admin/actions/media/cleanup.tsx
Normal file
59
web/source/settings/views/admin/actions/media/cleanup.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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/>.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { useMediaCleanupMutation } from "../../../../lib/query";
|
||||
import { useTextInput } from "../../../../lib/form";
|
||||
import { TextInput } from "../../../../components/form/inputs";
|
||||
import MutationButton from "../../../../components/form/mutation-button";
|
||||
|
||||
export default function Cleanup({}) {
|
||||
const daysField = useTextInput("days", { defaultValue: "30" });
|
||||
|
||||
const [mediaCleanup, mediaCleanupResult] = useMediaCleanupMutation();
|
||||
|
||||
function submitCleanup(e) {
|
||||
e.preventDefault();
|
||||
mediaCleanup(daysField.value);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submitCleanup}>
|
||||
<h2>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>
|
||||
<TextInput
|
||||
field={daysField}
|
||||
label="Days"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="30"
|
||||
/>
|
||||
<MutationButton
|
||||
disabled={!daysField.value}
|
||||
label="Remove old media"
|
||||
result={mediaCleanupResult}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
30
web/source/settings/views/admin/actions/media/index.tsx
Normal file
30
web/source/settings/views/admin/actions/media/index.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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/>.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import Cleanup from "./cleanup";
|
||||
|
||||
export default function Media() {
|
||||
return (
|
||||
<>
|
||||
<h1>Media Actions</h1>
|
||||
<Cleanup />
|
||||
</>
|
||||
);
|
||||
}
|
||||
134
web/source/settings/views/admin/emoji/category-select.tsx
Normal file
134
web/source/settings/views/admin/emoji/category-select.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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/>.
|
||||
*/
|
||||
|
||||
import React, { useMemo, useEffect, PropsWithChildren, ReactElement } from "react";
|
||||
import { matchSorter } from "match-sorter";
|
||||
import ComboBox from "../../../components/combo-box";
|
||||
import { useListEmojiQuery } from "../../../lib/query/admin/custom-emoji";
|
||||
import { CustomEmoji } from "../../../lib/types/custom-emoji";
|
||||
import { ComboboxFormInputHook } from "../../../lib/form/types";
|
||||
import Loading from "../../../components/loading";
|
||||
import { Error } from "../../../components/error";
|
||||
|
||||
/**
|
||||
* Sort all emoji into a map keyed by
|
||||
* the category names (or "Unsorted").
|
||||
*/
|
||||
export function useEmojiByCategory(emojis: CustomEmoji[]) {
|
||||
return useMemo(() => {
|
||||
const byCategory = new Map<string, CustomEmoji[]>();
|
||||
|
||||
emojis.forEach((emoji) => {
|
||||
const key = emoji.category ?? "Unsorted";
|
||||
const value = byCategory.get(key) ?? [];
|
||||
value.push(emoji);
|
||||
byCategory.set(key, value);
|
||||
});
|
||||
|
||||
return byCategory;
|
||||
}, [emojis]);
|
||||
}
|
||||
|
||||
interface CategorySelectProps {
|
||||
field: ComboboxFormInputHook;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Renders a cute lil searchable "category select" dropdown.
|
||||
*/
|
||||
export function CategorySelect({ field, children }: PropsWithChildren<CategorySelectProps>) {
|
||||
// Get all local emojis.
|
||||
const {
|
||||
data: emoji = [],
|
||||
isLoading,
|
||||
isSuccess,
|
||||
isError,
|
||||
error,
|
||||
} = useListEmojiQuery({ filter: "domain:local" });
|
||||
|
||||
const emojiByCategory = useEmojiByCategory(emoji);
|
||||
const categories = useMemo(() => new Set(emojiByCategory.keys()), [emojiByCategory]);
|
||||
const { value, setIsNew } = field;
|
||||
|
||||
// Data used by the ComboBox element
|
||||
// to select an emoji category.
|
||||
const categoryItems = useMemo(() => {
|
||||
const categoriesArr = Array.from(categories);
|
||||
|
||||
// Sorted by complex algorithm.
|
||||
const categoryNames = matchSorter(
|
||||
categoriesArr,
|
||||
value ?? "",
|
||||
{ threshold: matchSorter.rankings.NO_MATCH },
|
||||
);
|
||||
|
||||
// Map each category to the static image
|
||||
// of the first emoji it contains.
|
||||
const categoryItems: [string, ReactElement][] = [];
|
||||
categoryNames.forEach((categoryName) => {
|
||||
let src: string | undefined;
|
||||
const items = emojiByCategory.get(categoryName);
|
||||
if (items && items.length > 0) {
|
||||
src = items[0].static_url;
|
||||
}
|
||||
|
||||
categoryItems.push([
|
||||
categoryName,
|
||||
<>
|
||||
<img
|
||||
src={src}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{categoryName}
|
||||
</>
|
||||
]);
|
||||
});
|
||||
|
||||
return categoryItems;
|
||||
}, [emojiByCategory, categories, value]);
|
||||
|
||||
// New category if something has been entered
|
||||
// and we don't have it in categories yet.
|
||||
useEffect(() => {
|
||||
if (value !== undefined) {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length > 0) {
|
||||
setIsNew(!categories.has(trimmed));
|
||||
}
|
||||
}
|
||||
}, [categories, value, isSuccess, setIsNew]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading />;
|
||||
} else if (isError) {
|
||||
return <Error error={error} />;
|
||||
} else {
|
||||
return (
|
||||
<ComboBox
|
||||
field={field}
|
||||
items={categoryItems}
|
||||
label="Category"
|
||||
placeholder="e.g., reactions"
|
||||
>
|
||||
{children}
|
||||
</ComboBox>
|
||||
);
|
||||
}
|
||||
}
|
||||
142
web/source/settings/views/admin/emoji/local/detail.tsx
Normal file
142
web/source/settings/views/admin/emoji/local/detail.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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/>.
|
||||
*/
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import { Redirect, useParams } from "wouter";
|
||||
import { useComboBoxInput, useFileInput, useValue } from "../../../../lib/form";
|
||||
import useFormSubmit from "../../../../lib/form/submit";
|
||||
import { useBaseUrl } from "../../../../lib/navigation/util";
|
||||
import FakeToot from "../../../../components/fake-toot";
|
||||
import FormWithData from "../../../../lib/form/form-with-data";
|
||||
import Loading from "../../../../components/loading";
|
||||
import { FileInput } from "../../../../components/form/inputs";
|
||||
import MutationButton from "../../../../components/form/mutation-button";
|
||||
import { Error } from "../../../../components/error";
|
||||
import { useGetEmojiQuery, useEditEmojiMutation, useDeleteEmojiMutation } from "../../../../lib/query/admin/custom-emoji";
|
||||
import { CategorySelect } from "../category-select";
|
||||
import BackButton from "../../../../components/back-button";
|
||||
|
||||
export default function EmojiDetail() {
|
||||
const baseUrl = useBaseUrl();
|
||||
const params = useParams();
|
||||
return (
|
||||
<div className="emoji-detail">
|
||||
<BackButton to={`~${baseUrl}/local`} />
|
||||
<FormWithData dataQuery={useGetEmojiQuery} queryArg={params.emojiId} DataForm={EmojiDetailForm} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmojiDetailForm({ data: emoji }) {
|
||||
const baseUrl = useBaseUrl();
|
||||
const form = {
|
||||
id: useValue("id", emoji.id),
|
||||
category: useComboBoxInput("category", { source: emoji }),
|
||||
image: useFileInput("image", {
|
||||
withPreview: true,
|
||||
maxSize: 50 * 1024 // TODO: get from instance api
|
||||
})
|
||||
};
|
||||
|
||||
const [modifyEmoji, result] = useFormSubmit(form, useEditEmojiMutation());
|
||||
|
||||
// Automatic submitting of category change
|
||||
useEffect(() => {
|
||||
if (
|
||||
form.category.hasChanged() &&
|
||||
!form.category.state.open &&
|
||||
!form.category.isNew) {
|
||||
modifyEmoji();
|
||||
}
|
||||
/* eslint-disable-next-line react-hooks/exhaustive-deps */
|
||||
}, [form.category.hasChanged(), form.category.isNew, form.category.state.open]);
|
||||
|
||||
const [deleteEmoji, deleteResult] = useDeleteEmojiMutation();
|
||||
|
||||
if (deleteResult.isSuccess) {
|
||||
return <Redirect to={`~${baseUrl}/local`} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="emoji-header">
|
||||
<img src={emoji.url} alt={emoji.shortcode} title={emoji.shortcode} />
|
||||
<div>
|
||||
<h2>{emoji.shortcode}</h2>
|
||||
<MutationButton
|
||||
label="Delete"
|
||||
type="button"
|
||||
onClick={() => deleteEmoji(emoji.id)}
|
||||
className="danger"
|
||||
showError={false}
|
||||
result={deleteResult}
|
||||
disabled={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={modifyEmoji} className="left-border">
|
||||
<h2>Modify this emoji {result.isLoading && <Loading />}</h2>
|
||||
|
||||
<div className="update-category">
|
||||
<CategorySelect
|
||||
field={form.category}
|
||||
>
|
||||
<MutationButton
|
||||
name="create-category"
|
||||
label="Create"
|
||||
result={result}
|
||||
showError={false}
|
||||
style={{ visibility: (form.category.isNew ? "initial" : "hidden") }}
|
||||
disabled={!form.category.value}
|
||||
/>
|
||||
</CategorySelect>
|
||||
</div>
|
||||
|
||||
<div className="update-image">
|
||||
<FileInput
|
||||
field={form.image}
|
||||
label="Image"
|
||||
accept="image/png,image/gif"
|
||||
/>
|
||||
|
||||
<MutationButton
|
||||
name="image"
|
||||
label="Replace image"
|
||||
showError={false}
|
||||
result={result}
|
||||
disabled={!form.image.value}
|
||||
/>
|
||||
|
||||
<FakeToot>
|
||||
Look at this new custom emoji <img
|
||||
className="emoji"
|
||||
src={form.image.previewValue ?? emoji.url}
|
||||
title={`:${emoji.shortcode}:`}
|
||||
alt={emoji.shortcode}
|
||||
/> isn't it cool?
|
||||
</FakeToot>
|
||||
|
||||
{result.error && <Error error={result.error} />}
|
||||
{deleteResult.error && <Error error={deleteResult.error} />}
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
112
web/source/settings/views/admin/emoji/local/new-emoji.tsx
Normal file
112
web/source/settings/views/admin/emoji/local/new-emoji.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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/>.
|
||||
*/
|
||||
|
||||
import React, { useMemo, useEffect } from "react";
|
||||
import { useFileInput, useComboBoxInput } from "../../../../lib/form";
|
||||
import useShortcode from "./use-shortcode";
|
||||
import useFormSubmit from "../../../../lib/form/submit";
|
||||
import { TextInput, FileInput } from "../../../../components/form/inputs";
|
||||
import { CategorySelect } from '../category-select';
|
||||
import FakeToot from "../../../../components/fake-toot";
|
||||
import MutationButton from "../../../../components/form/mutation-button";
|
||||
import { useAddEmojiMutation } from "../../../../lib/query/admin/custom-emoji";
|
||||
import { useInstanceV1Query } from "../../../../lib/query";
|
||||
|
||||
export default function NewEmojiForm() {
|
||||
const shortcode = useShortcode();
|
||||
|
||||
const { data: instance } = useInstanceV1Query();
|
||||
const emojiMaxSize = useMemo(() => {
|
||||
return instance?.configuration?.emojis?.emoji_size_limit ?? 50 * 1024;
|
||||
}, [instance]);
|
||||
|
||||
const image = useFileInput("image", {
|
||||
withPreview: true,
|
||||
maxSize: emojiMaxSize
|
||||
});
|
||||
|
||||
const category = useComboBoxInput("category");
|
||||
|
||||
const [submitForm, result] = useFormSubmit({
|
||||
shortcode, image, category
|
||||
}, useAddEmojiMutation());
|
||||
|
||||
useEffect(() => {
|
||||
if (shortcode.value === undefined || shortcode.value.length == 0) {
|
||||
if (image.value != undefined) {
|
||||
let [name, _ext] = image.value.name.split(".");
|
||||
shortcode.setter(name);
|
||||
}
|
||||
}
|
||||
|
||||
/* 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]);
|
||||
|
||||
let emojiOrShortcode;
|
||||
|
||||
if (image.previewValue != undefined) {
|
||||
emojiOrShortcode = <img
|
||||
className="emoji"
|
||||
src={image.previewValue}
|
||||
title={`:${shortcode.value}:`}
|
||||
alt={shortcode.value}
|
||||
/>;
|
||||
} else if (shortcode.value !== undefined && shortcode.value.length > 0) {
|
||||
emojiOrShortcode = `:${shortcode.value}:`;
|
||||
} else {
|
||||
emojiOrShortcode = `:your_emoji_here:`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Add new custom emoji</h2>
|
||||
|
||||
<FakeToot>
|
||||
Look at this new custom emoji {emojiOrShortcode} isn't it cool?
|
||||
</FakeToot>
|
||||
|
||||
<form onSubmit={submitForm} className="form-flex">
|
||||
<FileInput
|
||||
field={image}
|
||||
accept="image/png,image/gif,image/webp"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
field={shortcode}
|
||||
label="Shortcode, must be unique among the instance's local emoji"
|
||||
/>
|
||||
|
||||
<CategorySelect
|
||||
field={category}
|
||||
children={[]}
|
||||
/>
|
||||
|
||||
<MutationButton
|
||||
disabled={image.previewValue === undefined}
|
||||
label="Upload emoji"
|
||||
result={result}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
173
web/source/settings/views/admin/emoji/local/overview.tsx
Normal file
173
web/source/settings/views/admin/emoji/local/overview.tsx
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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/>.
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Link } from "wouter";
|
||||
import { matchSorter } from "match-sorter";
|
||||
import NewEmojiForm from "./new-emoji";
|
||||
import { useTextInput } from "../../../../lib/form";
|
||||
import { useEmojiByCategory } from "../category-select";
|
||||
import Loading from "../../../../components/loading";
|
||||
import { Error } from "../../../../components/error";
|
||||
import { TextInput } from "../../../../components/form/inputs";
|
||||
import { useListEmojiQuery } from "../../../../lib/query/admin/custom-emoji";
|
||||
import { CustomEmoji } from "../../../../lib/types/custom-emoji";
|
||||
|
||||
export function EmojiOverview() {
|
||||
const { data: emoji = [], isLoading, isError, error } = useListEmojiQuery({ filter: "domain:local" });
|
||||
|
||||
let content: React.JSX.Element;
|
||||
if (isLoading) {
|
||||
content = <Loading />;
|
||||
} else if (isError) {
|
||||
content = <Error error={error} />;
|
||||
} else {
|
||||
content = (
|
||||
<>
|
||||
<EmojiList emoji={emoji} />
|
||||
<NewEmojiForm />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Local Custom Emoji</h1>
|
||||
<p>
|
||||
To use custom emoji in your toots they have to be 'local' to the instance.
|
||||
You can either upload them here directly, or copy from those already
|
||||
present on other (known) instances through the <Link to={`/remote`}>Remote Emoji</Link> page.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Be warned!</strong> If you upload more than about 300-400 custom emojis in
|
||||
total on your instance, this may lead to rate-limiting issues for users and clients
|
||||
if they try to load all the emoji images at once (which is what many clients do).
|
||||
</p>
|
||||
{content}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface EmojiListParams {
|
||||
emoji: CustomEmoji[];
|
||||
}
|
||||
|
||||
function EmojiList({ emoji }: EmojiListParams) {
|
||||
const filterField = useTextInput("filter");
|
||||
const filter = filterField.value ?? "";
|
||||
const emojiByCategory = useEmojiByCategory(emoji);
|
||||
|
||||
// Filter emoji based on shortcode match
|
||||
// with user input, hiding empty categories.
|
||||
const { filteredEmojis, filteredCount } = useMemo(() => {
|
||||
// Amount of emojis removed by the filter.
|
||||
// Start with the length of the array since
|
||||
// that's the max that can be filtered out.
|
||||
let filteredCount = emoji.length;
|
||||
|
||||
// Results of the filtering.
|
||||
const filteredEmojis: [string, CustomEmoji[]][] = [];
|
||||
|
||||
// Filter from emojis in this category.
|
||||
emojiByCategory.forEach((entries, category) => {
|
||||
const filteredEntries = matchSorter(entries, filter, {
|
||||
keys: ["shortcode"]
|
||||
});
|
||||
|
||||
if (filteredEntries.length == 0) {
|
||||
// Nothing left in this category, don't
|
||||
// bother adding it to filteredEmojis.
|
||||
return;
|
||||
}
|
||||
|
||||
filteredCount -= filteredEntries.length;
|
||||
filteredEmojis.push([category, filteredEntries]);
|
||||
});
|
||||
|
||||
return { filteredEmojis, filteredCount };
|
||||
}, [filter, emojiByCategory, emoji.length]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2>Overview</h2>
|
||||
{emoji.length > 0
|
||||
? <span>{emoji.length} custom emoji {filteredCount > 0 && `(${filteredCount} filtered)`}</span>
|
||||
: <span>No custom emoji yet, you can add one below.</span>
|
||||
}
|
||||
<div className="list emoji-list">
|
||||
<div className="header">
|
||||
<TextInput
|
||||
field={filterField}
|
||||
name="emoji-shortcode"
|
||||
placeholder="Search"
|
||||
/>
|
||||
</div>
|
||||
<div className="entries scrolling">
|
||||
{filteredEmojis.length > 0
|
||||
? (
|
||||
<div className="entries scrolling">
|
||||
{filteredEmojis.map(([category, emojis]) => {
|
||||
return <EmojiCategory key={category} category={category} emojis={emojis} />;
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
: <div className="entry">No local emoji matched your filter.</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface EmojiCategoryProps {
|
||||
category: string;
|
||||
emojis: CustomEmoji[];
|
||||
}
|
||||
|
||||
function EmojiCategory({ category, emojis }: EmojiCategoryProps) {
|
||||
return (
|
||||
<div className="entry">
|
||||
<b>{category}</b>
|
||||
<div className="emoji-group">
|
||||
{emojis.map((emoji) => {
|
||||
return (
|
||||
<Link key={emoji.id} to={`/local/${emoji.id}`} >
|
||||
<EmojiPreview emoji={emoji} />
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmojiPreview({ emoji }) {
|
||||
const [ animate, setAnimate ] = useState(false);
|
||||
|
||||
return (
|
||||
<img
|
||||
onMouseEnter={() => { setAnimate(true); }}
|
||||
onMouseLeave={() => { setAnimate(false); }}
|
||||
src={animate ? emoji.url : emoji.static_url}
|
||||
alt={emoji.shortcode}
|
||||
title={emoji.shortcode}
|
||||
loading="lazy"
|
||||
/>
|
||||
);
|
||||
}
|
||||
56
web/source/settings/views/admin/emoji/local/use-shortcode.ts
Normal file
56
web/source/settings/views/admin/emoji/local/use-shortcode.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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/>.
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useTextInput } from "../../../../lib/form";
|
||||
import { useListEmojiQuery } from "../../../../lib/query/admin/custom-emoji";
|
||||
|
||||
const shortcodeRegex = /^\w{2,30}$/;
|
||||
|
||||
export default function useShortcode() {
|
||||
const { data: emoji = [] } = useListEmojiQuery({
|
||||
filter: "domain:local"
|
||||
});
|
||||
|
||||
const emojiCodes = 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 (!shortcodeRegex.test(code)) {
|
||||
return "Shortcode must only contain letters, numbers, and underscores";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
});
|
||||
}
|
||||
46
web/source/settings/views/admin/emoji/remote/index.tsx
Normal file
46
web/source/settings/views/admin/emoji/remote/index.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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/>.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
|
||||
import StealThisLook from "./steal-this-look";
|
||||
|
||||
import Loading from "../../../../components/loading";
|
||||
import { Error } from "../../../../components/error";
|
||||
import { useListEmojiQuery } from "../../../../lib/query/admin/custom-emoji";
|
||||
|
||||
export default function RemoteEmoji() {
|
||||
// Local emoji are queried for
|
||||
// shortcode collision detection
|
||||
const {
|
||||
data: emoji = [],
|
||||
isLoading,
|
||||
error
|
||||
} = useListEmojiQuery({ filter: "domain:local" });
|
||||
|
||||
const emojiCodes = useMemo(() => new Set(emoji.map((e) => e.shortcode)), [emoji]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Custom Emoji (remote)</h1>
|
||||
{error && <Error error={error} />}
|
||||
{isLoading ? <Loading /> : <StealThisLook emojiCodes={emojiCodes} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
235
web/source/settings/views/admin/emoji/remote/steal-this-look.tsx
Normal file
235
web/source/settings/views/admin/emoji/remote/steal-this-look.tsx
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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/>.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect } from "react";
|
||||
|
||||
import { useTextInput, useComboBoxInput, useCheckListInput } from "../../../../lib/form";
|
||||
|
||||
import useFormSubmit from "../../../../lib/form/submit";
|
||||
|
||||
import CheckList from "../../../../components/check-list";
|
||||
import { CategorySelect } from '../category-select';
|
||||
|
||||
import { TextInput } from "../../../../components/form/inputs";
|
||||
import MutationButton from "../../../../components/form/mutation-button";
|
||||
import { Error } from "../../../../components/error";
|
||||
import { useSearchItemForEmojiMutation, usePatchRemoteEmojisMutation } from "../../../../lib/query/admin/custom-emoji";
|
||||
|
||||
export default function StealThisLook({ emojiCodes }) {
|
||||
const [searchStatus, result] = useSearchItemForEmojiMutation();
|
||||
const urlField = useTextInput("url");
|
||||
|
||||
function submitSearch(e) {
|
||||
e.preventDefault();
|
||||
if (urlField.value !== undefined && urlField.value.trim().length != 0) {
|
||||
searchStatus(urlField.value);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="parse-emoji">
|
||||
<h2>Steal this look</h2>
|
||||
<form onSubmit={submitSearch}>
|
||||
<div className="form-field text">
|
||||
<label htmlFor="url">
|
||||
Link to a status:
|
||||
</label>
|
||||
<div className="row">
|
||||
<input
|
||||
type="text"
|
||||
id="url"
|
||||
name="url"
|
||||
onChange={urlField.onChange}
|
||||
value={urlField.value}
|
||||
/>
|
||||
<button disabled={result.isLoading}>
|
||||
<i className={[
|
||||
"fa fa-fw",
|
||||
(result.isLoading
|
||||
? "fa-refresh fa-spin"
|
||||
: "fa-search")
|
||||
].join(" ")} aria-hidden="true" title="Search" />
|
||||
<span className="sr-only">Search</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<SearchResult result={result} localEmojiCodes={emojiCodes} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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/status, 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" ? "status" : "account"} doesn't use any custom emoji</b>;
|
||||
}
|
||||
|
||||
return (
|
||||
<CopyEmojiForm
|
||||
localEmojiCodes={localEmojiCodes}
|
||||
type={data.type}
|
||||
emojiList={data.list}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyEmojiForm({ localEmojiCodes, type, emojiList }) {
|
||||
const form = {
|
||||
selectedEmoji: useCheckListInput("selectedEmoji", {
|
||||
entries: emojiList,
|
||||
uniqueKey: "id"
|
||||
}),
|
||||
category: useComboBoxInput("category")
|
||||
};
|
||||
|
||||
const [formSubmit, result] = useFormSubmit(
|
||||
form,
|
||||
usePatchRemoteEmojisMutation(),
|
||||
{
|
||||
changedOnly: false,
|
||||
onFinish: ({ data }) => {
|
||||
if (data) {
|
||||
// uncheck all successfully processed emoji
|
||||
const processed = data.map((emoji) => {
|
||||
return [emoji.id, { checked: false }];
|
||||
});
|
||||
form.selectedEmoji.updateMultiple(processed);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const buttonsInactive = form.selectedEmoji.someSelected
|
||||
? {
|
||||
disabled: false,
|
||||
title: ""
|
||||
}
|
||||
: {
|
||||
disabled: true,
|
||||
title: "No emoji selected, cannot perform any actions"
|
||||
};
|
||||
|
||||
const checkListExtraProps = useCallback(() => ({ localEmojiCodes }), [localEmojiCodes]);
|
||||
|
||||
return (
|
||||
<div className="parsed">
|
||||
<span>This {type == "statuses" ? "status" : "account"} uses the following custom emoji, select the ones you want to copy/disable:</span>
|
||||
<form onSubmit={formSubmit}>
|
||||
<CheckList
|
||||
field={form.selectedEmoji}
|
||||
header={<></>}
|
||||
EntryComponent={EmojiEntry}
|
||||
getExtraProps={checkListExtraProps}
|
||||
/>
|
||||
|
||||
<CategorySelect
|
||||
field={form.category}
|
||||
children={[]}
|
||||
/>
|
||||
|
||||
<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 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, onChange, extraProps: { localEmojiCodes } }) {
|
||||
const shortcodeField = useTextInput("shortcode", {
|
||||
defaultValue: emoji.shortcode,
|
||||
validator: function validateShortcode(code) {
|
||||
return (emoji.checked && localEmojiCodes.has(code))
|
||||
? "Shortcode already in use"
|
||||
: "";
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (emoji.valid != shortcodeField.valid) {
|
||||
onChange({ valid: shortcodeField.valid });
|
||||
}
|
||||
}, [onChange, emoji.valid, shortcodeField.valid]);
|
||||
|
||||
useEffect(() => {
|
||||
shortcodeField.validate();
|
||||
// only need this update if it's the emoji.checked that updated, not shortcodeField
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [emoji.checked]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<img className="emoji" src={emoji.url} title={emoji.shortcode} />
|
||||
|
||||
<TextInput
|
||||
field={shortcodeField}
|
||||
onChange={(e) => {
|
||||
shortcodeField.onChange(e);
|
||||
onChange({ shortcode: e.target.value, checked: true });
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
177
web/source/settings/views/admin/routes.tsx
Normal file
177
web/source/settings/views/admin/routes.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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/>.
|
||||
*/
|
||||
|
||||
import { MenuItem } from "../../lib/navigation/menu";
|
||||
import React from "react";
|
||||
import { BaseUrlContext, useBaseUrl } from "../../lib/navigation/util";
|
||||
import { Route, Router, Switch } from "wouter";
|
||||
import EmojiDetail from "./emoji/local/detail";
|
||||
import { EmojiOverview } from "./emoji/local/overview";
|
||||
import RemoteEmoji from "./emoji/remote";
|
||||
import InstanceSettings from "./settings";
|
||||
import { InstanceRuleDetail, InstanceRules } from "./settings/rules";
|
||||
import Media from "./actions/media";
|
||||
import Keys from "./actions/keys";
|
||||
|
||||
/*
|
||||
EXPORTED COMPONENTS
|
||||
*/
|
||||
|
||||
/**
|
||||
* Admininistration menu. Admin actions,
|
||||
* emoji import, instance settings.
|
||||
*/
|
||||
export function AdminMenu() {
|
||||
return (
|
||||
<MenuItem
|
||||
name="Administration"
|
||||
itemUrl="admin"
|
||||
defaultChild="actions"
|
||||
permissions={["admin"]}
|
||||
>
|
||||
<MenuItem
|
||||
name="Instance Settings"
|
||||
itemUrl="instance-settings"
|
||||
icon="fa-sliders"
|
||||
/>
|
||||
<MenuItem
|
||||
name="Instance Rules"
|
||||
itemUrl="instance-rules"
|
||||
icon="fa-dot-circle-o"
|
||||
/>
|
||||
<AdminEmojisMenu />
|
||||
<AdminActionsMenu />
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Admininistration router. Admin actions,
|
||||
* emoji import, instance settings.
|
||||
*/
|
||||
export function AdminRouter() {
|
||||
const parentUrl = useBaseUrl();
|
||||
const thisBase = "/admin";
|
||||
const absBase = parentUrl + thisBase;
|
||||
|
||||
return (
|
||||
<BaseUrlContext.Provider value={absBase}>
|
||||
<Router base={thisBase}>
|
||||
<Route path="/instance-settings" component={InstanceSettings}/>
|
||||
<Route path="/instance-rules" component={InstanceRules} />
|
||||
<Route path="/instance-rules/:ruleId" component={InstanceRuleDetail} />
|
||||
<AdminEmojisRouter />
|
||||
<AdminActionsRouter />
|
||||
</Router>
|
||||
</BaseUrlContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
INTERNAL COMPONENTS
|
||||
*/
|
||||
|
||||
/*
|
||||
MENUS
|
||||
*/
|
||||
|
||||
function AdminActionsMenu() {
|
||||
return (
|
||||
<MenuItem
|
||||
name="Actions"
|
||||
itemUrl="actions"
|
||||
defaultChild="media"
|
||||
icon="fa-bolt"
|
||||
>
|
||||
<MenuItem
|
||||
name="Media"
|
||||
itemUrl="media"
|
||||
icon="fa-photo"
|
||||
/>
|
||||
<MenuItem
|
||||
name="Keys"
|
||||
itemUrl="keys"
|
||||
icon="fa-key-modern"
|
||||
/>
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminEmojisMenu() {
|
||||
return (
|
||||
<MenuItem
|
||||
name="Custom Emoji"
|
||||
itemUrl="emojis"
|
||||
defaultChild="local"
|
||||
icon="fa-smile-o"
|
||||
>
|
||||
<MenuItem
|
||||
name="Local"
|
||||
itemUrl="local"
|
||||
icon="fa-home"
|
||||
/>
|
||||
<MenuItem
|
||||
name="Remote"
|
||||
itemUrl="remote"
|
||||
icon="fa-cloud"
|
||||
/>
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
ROUTERS
|
||||
*/
|
||||
|
||||
function AdminEmojisRouter() {
|
||||
const parentUrl = useBaseUrl();
|
||||
const thisBase = "/emojis";
|
||||
const absBase = parentUrl + thisBase;
|
||||
|
||||
return (
|
||||
<BaseUrlContext.Provider value={absBase}>
|
||||
<Router base={thisBase}>
|
||||
<Switch>
|
||||
<Route path="/local/:emojiId" component={EmojiDetail} />
|
||||
<Route path="/local" component={EmojiOverview} />
|
||||
<Route path="/remote" component={RemoteEmoji} />
|
||||
<Route component={EmojiOverview}/>
|
||||
</Switch>
|
||||
</Router>
|
||||
</BaseUrlContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminActionsRouter() {
|
||||
const parentUrl = useBaseUrl();
|
||||
const thisBase = "/actions";
|
||||
const absBase = parentUrl + thisBase;
|
||||
|
||||
return (
|
||||
<BaseUrlContext.Provider value={absBase}>
|
||||
<Router base={thisBase}>
|
||||
<Switch>
|
||||
<Route path="/media" component={Media} />
|
||||
<Route path="/keys" component={Keys} />
|
||||
<Route component={Media}/>
|
||||
</Switch>
|
||||
</Router>
|
||||
</BaseUrlContext.Provider>
|
||||
);
|
||||
}
|
||||
190
web/source/settings/views/admin/settings/index.tsx
Normal file
190
web/source/settings/views/admin/settings/index.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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/>.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { useTextInput, useFileInput } from "../../../lib/form";
|
||||
|
||||
const useFormSubmit = require("../../../lib/form/submit").default;
|
||||
|
||||
import { TextInput, TextArea, FileInput } from "../../../components/form/inputs";
|
||||
|
||||
const FormWithData = require("../../../lib/form/form-with-data").default;
|
||||
import MutationButton from "../../../components/form/mutation-button";
|
||||
|
||||
import { useInstanceV1Query } from "../../../lib/query";
|
||||
import { useUpdateInstanceMutation } from "../../../lib/query/admin";
|
||||
import { InstanceV1 } from "../../../lib/types/instance";
|
||||
|
||||
export default function InstanceSettings() {
|
||||
return (
|
||||
<FormWithData
|
||||
dataQuery={useInstanceV1Query}
|
||||
DataForm={InstanceSettingsForm}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface InstanceSettingsFormProps{
|
||||
data: InstanceV1;
|
||||
}
|
||||
|
||||
function InstanceSettingsForm({ data: instance }: InstanceSettingsFormProps) {
|
||||
const titleLimit = 40;
|
||||
const shortDescLimit = 500;
|
||||
const descLimit = 5000;
|
||||
const termsLimit = 5000;
|
||||
|
||||
const form = {
|
||||
title: useTextInput("title", {
|
||||
source: instance,
|
||||
validator: (val: string) => val.length <= titleLimit ? "" : `Instance title is ${val.length} characters; must be ${titleLimit} characters or less`
|
||||
}),
|
||||
thumbnail: useFileInput("thumbnail", { withPreview: true }),
|
||||
thumbnailDesc: useTextInput("thumbnail_description", { source: instance }),
|
||||
shortDesc: useTextInput("short_description", {
|
||||
source: instance,
|
||||
// Select "raw" text version of parsed field for editing.
|
||||
valueSelector: (s: InstanceV1) => s.short_description_text,
|
||||
validator: (val: string) => val.length <= shortDescLimit ? "" : `Instance short description is ${val.length} characters; must be ${shortDescLimit} characters or less`
|
||||
}),
|
||||
description: useTextInput("description", {
|
||||
source: instance,
|
||||
// Select "raw" text version of parsed field for editing.
|
||||
valueSelector: (s: InstanceV1) => s.description_text,
|
||||
validator: (val: string) => val.length <= descLimit ? "" : `Instance description is ${val.length} characters; must be ${descLimit} characters or less`
|
||||
}),
|
||||
terms: useTextInput("terms", {
|
||||
source: instance,
|
||||
// Select "raw" text version of parsed field for editing.
|
||||
valueSelector: (s: InstanceV1) => s.terms_text,
|
||||
validator: (val: string) => val.length <= termsLimit ? "" : `Instance terms and conditions is ${val.length} characters; must be ${termsLimit} characters or less`
|
||||
}),
|
||||
contactUser: useTextInput("contact_username", { source: instance, valueSelector: (s) => s.contact_account?.username }),
|
||||
contactEmail: useTextInput("contact_email", { source: instance, valueSelector: (s) => s.email })
|
||||
};
|
||||
|
||||
const [submitForm, result] = useFormSubmit(form, useUpdateInstanceMutation());
|
||||
|
||||
return (
|
||||
<form onSubmit={submitForm}>
|
||||
<h1>Instance Settings</h1>
|
||||
|
||||
<div className="form-section-docs">
|
||||
<h3>Appearance</h3>
|
||||
<a
|
||||
href="https://docs.gotosocial.org/en/latest/admin/settings/#instance-appearance"
|
||||
target="_blank"
|
||||
className="docslink"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more about these settings (opens in a new tab)
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<TextInput
|
||||
field={form.title}
|
||||
label={`Instance title (max ${titleLimit} characters)`}
|
||||
placeholder="My GoToSocial instance"
|
||||
/>
|
||||
|
||||
<div className="file-upload" aria-labelledby="avatar">
|
||||
<strong id="avatar">Instance avatar (1:1 images look best)</strong>
|
||||
<div className="file-upload-with-preview">
|
||||
<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")}
|
||||
/>
|
||||
<div className="file-input-with-image-description">
|
||||
<FileInput
|
||||
field={form.thumbnail}
|
||||
accept="image/png, image/jpeg, image/webp, image/gif"
|
||||
/>
|
||||
<TextInput
|
||||
field={form.thumbnailDesc}
|
||||
label="Avatar image description"
|
||||
placeholder="A cute drawing of a smiling sloth."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="form-section-docs">
|
||||
<h3>Descriptors</h3>
|
||||
<a
|
||||
href="https://docs.gotosocial.org/en/latest/admin/settings/#instance-descriptors"
|
||||
target="_blank"
|
||||
className="docslink"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more about these settings (opens in a new tab)
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<TextArea
|
||||
field={form.shortDesc}
|
||||
label={`Short description (markdown accepted, max ${shortDescLimit} characters)`}
|
||||
placeholder="A small testing instance for the GoToSocial alpha software."
|
||||
rows={6}
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
field={form.description}
|
||||
label={`Full description (markdown accepted, max ${descLimit} characters)`}
|
||||
placeholder="A small testing instance for the GoToSocial alpha software. Just trying it out, my main instance is https://example.com"
|
||||
rows={6}
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
field={form.terms}
|
||||
label={`Terms & Conditions (markdown accepted, max ${termsLimit} characters)`}
|
||||
placeholder="Terms and conditions of using this instance, data policy, imprint, GDPR stuff, yadda yadda."
|
||||
rows={6}
|
||||
/>
|
||||
|
||||
<div className="form-section-docs">
|
||||
<h3>Contact info</h3>
|
||||
<a
|
||||
href="https://docs.gotosocial.org/en/latest/admin/settings/#instance-contact-info"
|
||||
target="_blank"
|
||||
className="docslink"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more about these settings (opens in a new tab)
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<TextInput
|
||||
field={form.contactUser}
|
||||
label="Contact user (local account username)"
|
||||
placeholder="admin"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
field={form.contactEmail}
|
||||
label="Contact email"
|
||||
placeholder="admin@example.com"
|
||||
/>
|
||||
|
||||
<MutationButton label="Save" result={result} disabled={false} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
151
web/source/settings/views/admin/settings/rules.tsx
Normal file
151
web/source/settings/views/admin/settings/rules.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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/>.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Link, Redirect, useParams } from "wouter";
|
||||
import { useInstanceRulesQuery, useAddInstanceRuleMutation, useUpdateInstanceRuleMutation, useDeleteInstanceRuleMutation } from "../../../lib/query";
|
||||
import { useBaseUrl } from "../../../lib/navigation/util";
|
||||
import { useValue, useTextInput } from "../../../lib/form";
|
||||
import useFormSubmit from "../../../lib/form/submit";
|
||||
import { TextArea } from "../../../components/form/inputs";
|
||||
import MutationButton from "../../../components/form/mutation-button";
|
||||
import { Error } from "../../../components/error";
|
||||
import BackButton from "../../../components/back-button";
|
||||
import { InstanceRule, MappedRules } from "../../../lib/types/rules";
|
||||
import Loading from "../../../components/loading";
|
||||
import FormWithData from "../../../lib/form/form-with-data";
|
||||
|
||||
export function InstanceRules() {
|
||||
return (
|
||||
<>
|
||||
<h1>Instance Rules</h1>
|
||||
<FormWithData
|
||||
dataQuery={useInstanceRulesQuery}
|
||||
DataForm={InstanceRulesForm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function InstanceRulesForm({ data: rules }: { data: MappedRules }) {
|
||||
const baseUrl = useBaseUrl();
|
||||
const newRule = useTextInput("text");
|
||||
|
||||
const [submitForm, result] = useFormSubmit({ newRule }, useAddInstanceRuleMutation(), {
|
||||
changedOnly: true,
|
||||
onFinish: () => newRule.reset()
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={submitForm} className="new-rule">
|
||||
<ol className="instance-rules">
|
||||
{Object.values(rules).map((rule: InstanceRule) => (
|
||||
<Link className="rule" to={`~${baseUrl}/instance-rules/${rule.id}`}>
|
||||
<li>
|
||||
<h2>{rule.text} <i className="fa fa-pencil edit-icon" /></h2>
|
||||
</li>
|
||||
<span>{new Date(rule.created_at).toLocaleString()}</span>
|
||||
</Link>
|
||||
))}
|
||||
</ol>
|
||||
<TextArea
|
||||
field={newRule}
|
||||
label="New instance rule"
|
||||
/>
|
||||
<MutationButton
|
||||
disabled={newRule.value === undefined || newRule.value.length === 0}
|
||||
label="Add rule"
|
||||
result={result}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function InstanceRuleDetail() {
|
||||
const baseUrl = useBaseUrl();
|
||||
const params: { ruleId: string } = useParams();
|
||||
|
||||
const { data: rules, isLoading, isError, error } = useInstanceRulesQuery();
|
||||
if (isLoading) {
|
||||
return <Loading />;
|
||||
} else if (isError) {
|
||||
return <Error error={error} />;
|
||||
}
|
||||
|
||||
if (rules === undefined) {
|
||||
throw "undefined rules";
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<BackButton to={`~${baseUrl}/instance-rules`} />
|
||||
<EditInstanceRuleForm rule={rules[params.ruleId]} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EditInstanceRuleForm({ rule }) {
|
||||
const baseUrl = useBaseUrl();
|
||||
const form = {
|
||||
id: useValue("id", rule.id),
|
||||
rule: useTextInput("text", { defaultValue: rule.text })
|
||||
};
|
||||
|
||||
const [submitForm, result] = useFormSubmit(form, useUpdateInstanceRuleMutation());
|
||||
|
||||
const [deleteRule, deleteResult] = useDeleteInstanceRuleMutation({ fixedCacheKey: rule.id });
|
||||
|
||||
if (result.isSuccess || deleteResult.isSuccess) {
|
||||
return (
|
||||
<Redirect to={`~${baseUrl}/instance-rules`} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rule-detail">
|
||||
<form onSubmit={submitForm}>
|
||||
<TextArea
|
||||
field={form.rule}
|
||||
/>
|
||||
|
||||
<div className="action-buttons row">
|
||||
<MutationButton
|
||||
label="Save"
|
||||
showError={false}
|
||||
result={result}
|
||||
disabled={!form.rule.hasChanged()}
|
||||
/>
|
||||
|
||||
<MutationButton
|
||||
disabled={false}
|
||||
type="button"
|
||||
onClick={() => deleteRule(rule.id)}
|
||||
label="Delete"
|
||||
className="button danger"
|
||||
showError={false}
|
||||
result={deleteResult}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{result.error && <Error error={result.error} />}
|
||||
{deleteResult.error && <Error error={deleteResult.error} />}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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/>.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { useActionAccountMutation } from "../../../../lib/query";
|
||||
|
||||
import MutationButton from "../../../../components/form/mutation-button";
|
||||
|
||||
import useFormSubmit from "../../../../lib/form/submit";
|
||||
import {
|
||||
useValue,
|
||||
useTextInput,
|
||||
useBoolInput,
|
||||
} from "../../../../lib/form";
|
||||
|
||||
import { Checkbox, TextInput } from "../../../../components/form/inputs";
|
||||
import { AdminAccount } from "../../../../lib/types/account";
|
||||
|
||||
export interface AccountActionsProps {
|
||||
account: AdminAccount,
|
||||
}
|
||||
|
||||
export function AccountActions({ account }: AccountActionsProps) {
|
||||
const form = {
|
||||
id: useValue("id", account.id),
|
||||
reason: useTextInput("text")
|
||||
};
|
||||
|
||||
const reallySuspend = useBoolInput("reallySuspend");
|
||||
const [accountAction, result] = useFormSubmit(form, useActionAccountMutation());
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={accountAction}
|
||||
aria-labelledby="account-moderation-actions"
|
||||
>
|
||||
<h3 id="account-moderation-actions">Account Moderation Actions</h3>
|
||||
<div>
|
||||
Currently only the "suspend" action is implemented.<br/>
|
||||
Suspending an account will delete it from your server, and remove all of its media, posts, relationships, etc.<br/>
|
||||
If the suspended account is local, suspending will also send out a "delete" message to other servers, requesting them to remove its data from their instance as well.<br/>
|
||||
<b>Account suspension cannot be reversed.</b>
|
||||
</div>
|
||||
<TextInput
|
||||
field={form.reason}
|
||||
placeholder="Reason for this action"
|
||||
/>
|
||||
<div className="action-buttons">
|
||||
{/* <MutationButton
|
||||
label="Disable"
|
||||
name="disable"
|
||||
result={result}
|
||||
/>
|
||||
<MutationButton
|
||||
label="Silence"
|
||||
name="silence"
|
||||
result={result}
|
||||
/> */}
|
||||
<MutationButton
|
||||
disabled={account.suspended || reallySuspend.value === undefined || reallySuspend.value === false}
|
||||
label="Suspend"
|
||||
name="suspend"
|
||||
result={result}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Really suspend"
|
||||
field={reallySuspend}
|
||||
></Checkbox>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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/>.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { useLocation } from "wouter";
|
||||
|
||||
import { useHandleSignupMutation } from "../../../../lib/query";
|
||||
|
||||
import MutationButton from "../../../../components/form/mutation-button";
|
||||
|
||||
import useFormSubmit from "../../../../lib/form/submit";
|
||||
import {
|
||||
useValue,
|
||||
useTextInput,
|
||||
useBoolInput,
|
||||
} from "../../../../lib/form";
|
||||
|
||||
import { Checkbox, Select, TextInput } from "../../../../components/form/inputs";
|
||||
import { AdminAccount } from "../../../../lib/types/account";
|
||||
|
||||
export interface HandleSignupProps {
|
||||
account: AdminAccount,
|
||||
backLocation: string,
|
||||
}
|
||||
|
||||
export function HandleSignup({account, backLocation}: HandleSignupProps) {
|
||||
const form = {
|
||||
id: useValue("id", account.id),
|
||||
approveOrReject: useTextInput("approve_or_reject", { defaultValue: "approve" }),
|
||||
privateComment: useTextInput("private_comment"),
|
||||
message: useTextInput("message"),
|
||||
sendEmail: useBoolInput("send_email"),
|
||||
};
|
||||
|
||||
const [_location, setLocation] = useLocation();
|
||||
|
||||
const [handleSignup, result] = useFormSubmit(form, useHandleSignupMutation(), {
|
||||
changedOnly: false,
|
||||
// After submitting the form, redirect back to
|
||||
// /settings/admin/accounts if rejecting, since
|
||||
// account will no longer be available at
|
||||
// /settings/admin/accounts/:accountID endpoint.
|
||||
onFinish: (res) => {
|
||||
if (form.approveOrReject.value === "approve") {
|
||||
// An approve request:
|
||||
// stay on this page and
|
||||
// serve updated details.
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.data) {
|
||||
// "reject" successful,
|
||||
// redirect to accounts page.
|
||||
setLocation(backLocation);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSignup}
|
||||
aria-labelledby="account-handle-signup"
|
||||
>
|
||||
<h3 id="account-handle-signup">Handle Account Sign-Up</h3>
|
||||
<Select
|
||||
field={form.approveOrReject}
|
||||
label="Approve or Reject"
|
||||
options={
|
||||
<>
|
||||
<option value="approve">Approve</option>
|
||||
<option value="reject">Reject</option>
|
||||
</>
|
||||
}
|
||||
>
|
||||
</Select>
|
||||
{ form.approveOrReject.value === "reject" &&
|
||||
// Only show form fields relevant
|
||||
// to "reject" if rejecting.
|
||||
// On "approve" these fields will
|
||||
// be ignored anyway.
|
||||
<>
|
||||
<TextInput
|
||||
field={form.privateComment}
|
||||
label="(Optional) private comment on why sign-up was rejected (shown to other admins only)"
|
||||
/>
|
||||
<Checkbox
|
||||
field={form.sendEmail}
|
||||
label="Send email to applicant"
|
||||
/>
|
||||
<TextInput
|
||||
field={form.message}
|
||||
label={"(Optional) message to include in email to applicant, if send email is checked"}
|
||||
/>
|
||||
</> }
|
||||
<MutationButton
|
||||
disabled={false}
|
||||
label={form.approveOrReject.value === "approve" ? "Approve" : "Reject"}
|
||||
result={result}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
167
web/source/settings/views/moderation/accounts/detail/index.tsx
Normal file
167
web/source/settings/views/moderation/accounts/detail/index.tsx
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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/>.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { useGetAccountQuery } from "../../../../lib/query";
|
||||
|
||||
import FormWithData from "../../../../lib/form/form-with-data";
|
||||
|
||||
import FakeProfile from "../../../../components/fake-profile";
|
||||
|
||||
import { AdminAccount } from "../../../../lib/types/account";
|
||||
import { HandleSignup } from "./handlesignup";
|
||||
import { AccountActions } from "./actions";
|
||||
import { useParams } from "wouter";
|
||||
|
||||
export default function AccountDetail() {
|
||||
const params: { accountID: string } = useParams();
|
||||
|
||||
return (
|
||||
<div className="account-detail">
|
||||
<h1>Account Details</h1>
|
||||
<FormWithData
|
||||
dataQuery={useGetAccountQuery}
|
||||
queryArg={params.accountID}
|
||||
DataForm={AccountDetailForm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AccountDetailFormProps {
|
||||
backLocation: string,
|
||||
data: AdminAccount,
|
||||
}
|
||||
|
||||
function AccountDetailForm({ data: adminAcct, backLocation }: AccountDetailFormProps) {
|
||||
let yesOrNo = (b: boolean) => {
|
||||
return b ? "yes" : "no";
|
||||
};
|
||||
|
||||
let created = new Date(adminAcct.created_at).toDateString();
|
||||
let lastPosted = "never";
|
||||
if (adminAcct.account.last_status_at) {
|
||||
lastPosted = new Date(adminAcct.account.last_status_at).toDateString();
|
||||
}
|
||||
const local = !adminAcct.domain;
|
||||
|
||||
return (
|
||||
<>
|
||||
<FakeProfile {...adminAcct.account} />
|
||||
<h3>General Account Details</h3>
|
||||
{ adminAcct.suspended &&
|
||||
<div className="info">
|
||||
<i className="fa fa-fw fa-info-circle" aria-hidden="true"></i>
|
||||
<b>Account is suspended.</b>
|
||||
</div>
|
||||
}
|
||||
<dl className="info-list">
|
||||
{ !local &&
|
||||
<div className="info-list-entry">
|
||||
<dt>Domain</dt>
|
||||
<dd>{adminAcct.domain}</dd>
|
||||
</div>}
|
||||
<div className="info-list-entry">
|
||||
<dt>Created</dt>
|
||||
<dd><time dateTime={adminAcct.created_at}>{created}</time></dd>
|
||||
</div>
|
||||
<div className="info-list-entry">
|
||||
<dt>Last posted</dt>
|
||||
<dd>{lastPosted}</dd>
|
||||
</div>
|
||||
<div className="info-list-entry">
|
||||
<dt>Suspended</dt>
|
||||
<dd>{yesOrNo(adminAcct.suspended)}</dd>
|
||||
</div>
|
||||
<div className="info-list-entry">
|
||||
<dt>Silenced</dt>
|
||||
<dd>{yesOrNo(adminAcct.silenced)}</dd>
|
||||
</div>
|
||||
<div className="info-list-entry">
|
||||
<dt>Statuses</dt>
|
||||
<dd>{adminAcct.account.statuses_count}</dd>
|
||||
</div>
|
||||
<div className="info-list-entry">
|
||||
<dt>Followers</dt>
|
||||
<dd>{adminAcct.account.followers_count}</dd>
|
||||
</div>
|
||||
<div className="info-list-entry">
|
||||
<dt>Following</dt>
|
||||
<dd>{adminAcct.account.following_count}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{ local &&
|
||||
// Only show local account details
|
||||
// if this is a local account!
|
||||
<>
|
||||
<h3>Local Account Details</h3>
|
||||
{ !adminAcct.approved &&
|
||||
<div className="info">
|
||||
<i className="fa fa-fw fa-info-circle" aria-hidden="true"></i>
|
||||
<b>Account is pending.</b>
|
||||
</div>
|
||||
}
|
||||
{ !adminAcct.confirmed &&
|
||||
<div className="info">
|
||||
<i className="fa fa-fw fa-info-circle" aria-hidden="true"></i>
|
||||
<b>Account email not yet confirmed.</b>
|
||||
</div>
|
||||
}
|
||||
<dl className="info-list">
|
||||
<div className="info-list-entry">
|
||||
<dt>Email</dt>
|
||||
<dd>{adminAcct.email} {<b>{adminAcct.confirmed ? "(confirmed)" : "(not confirmed)"}</b> }</dd>
|
||||
</div>
|
||||
<div className="info-list-entry">
|
||||
<dt>Disabled</dt>
|
||||
<dd>{yesOrNo(adminAcct.disabled)}</dd>
|
||||
</div>
|
||||
<div className="info-list-entry">
|
||||
<dt>Approved</dt>
|
||||
<dd>{yesOrNo(adminAcct.approved)}</dd>
|
||||
</div>
|
||||
<div className="info-list-entry">
|
||||
<dt>Sign-Up Reason</dt>
|
||||
<dd>{adminAcct.invite_request ?? <i>none provided</i>}</dd>
|
||||
</div>
|
||||
{ (adminAcct.ip && adminAcct.ip !== "0.0.0.0") &&
|
||||
<div className="info-list-entry">
|
||||
<dt>Sign-Up IP</dt>
|
||||
<dd>{adminAcct.ip}</dd>
|
||||
</div> }
|
||||
{ adminAcct.locale &&
|
||||
<div className="info-list-entry">
|
||||
<dt>Locale</dt>
|
||||
<dd>{adminAcct.locale}</dd>
|
||||
</div> }
|
||||
</dl>
|
||||
</> }
|
||||
{ local && !adminAcct.approved
|
||||
?
|
||||
<HandleSignup
|
||||
account={adminAcct}
|
||||
backLocation={backLocation}
|
||||
/>
|
||||
:
|
||||
<AccountActions account={adminAcct} />
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
35
web/source/settings/views/moderation/accounts/index.tsx
Normal file
35
web/source/settings/views/moderation/accounts/index.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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/>.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { AccountSearchForm } from "./search";
|
||||
|
||||
export default function AccountsOverview({ }) {
|
||||
return (
|
||||
<div className="accounts-view">
|
||||
<h1>Accounts Overview</h1>
|
||||
<span>
|
||||
You can perform actions on an account by clicking
|
||||
its name in a report, or by searching for the account
|
||||
using the form below and clicking on its name.
|
||||
</span>
|
||||
<AccountSearchForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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/>.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { useSearchAccountsQuery } from "../../../../lib/query";
|
||||
import { AccountList } from "../../../../components/account-list";
|
||||
|
||||
export default function AccountsPending() {
|
||||
const searchRes = useSearchAccountsQuery({status: "pending"});
|
||||
|
||||
return (
|
||||
<div className="accounts-view">
|
||||
<h1>Pending Accounts</h1>
|
||||
<AccountList
|
||||
isLoading={searchRes.isLoading}
|
||||
isSuccess={searchRes.isSuccess}
|
||||
data={searchRes.data}
|
||||
isError={searchRes.isError}
|
||||
error={searchRes.error}
|
||||
emptyMessage="No pending account sign-ups."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
131
web/source/settings/views/moderation/accounts/search/index.tsx
Normal file
131
web/source/settings/views/moderation/accounts/search/index.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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/>.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { useLazySearchAccountsQuery } from "../../../../lib/query";
|
||||
import { useTextInput } from "../../../../lib/form";
|
||||
|
||||
import { AccountList } from "../../../../components/account-list";
|
||||
import { SearchAccountParams } from "../../../../lib/types/account";
|
||||
import { Select, TextInput } from "../../../../components/form/inputs";
|
||||
import MutationButton from "../../../../components/form/mutation-button";
|
||||
|
||||
export function AccountSearchForm() {
|
||||
const form = {
|
||||
origin: useTextInput("origin"),
|
||||
status: useTextInput("status"),
|
||||
permissions: useTextInput("permissions"),
|
||||
username: useTextInput("username"),
|
||||
display_name: useTextInput("display_name"),
|
||||
by_domain: useTextInput("by_domain"),
|
||||
email: useTextInput("email"),
|
||||
ip: useTextInput("ip"),
|
||||
};
|
||||
|
||||
function submitSearch(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Parse query parameters.
|
||||
const entries = Object.entries(form).map(([k, v]) => {
|
||||
// Take only defined form fields.
|
||||
if (v.value === undefined || v.value.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return [[k, v.value]];
|
||||
}).flatMap(kv => {
|
||||
// Remove any nulls.
|
||||
return kv || [];
|
||||
});
|
||||
const params: SearchAccountParams = Object.fromEntries(entries);
|
||||
searchAcct(params);
|
||||
}
|
||||
|
||||
const [ searchAcct, searchRes ] = useLazySearchAccountsQuery();
|
||||
|
||||
return (
|
||||
<>
|
||||
<form
|
||||
onSubmit={submitSearch}
|
||||
// Prevent password managers trying
|
||||
// to fill in username/email fields.
|
||||
autoComplete="off"
|
||||
>
|
||||
<TextInput
|
||||
field={form.username}
|
||||
label={"(Optional) username (without leading '@' symbol)"}
|
||||
placeholder="someone"
|
||||
/>
|
||||
<TextInput
|
||||
field={form.by_domain}
|
||||
label={"(Optional) domain"}
|
||||
placeholder="example.org"
|
||||
/>
|
||||
<Select
|
||||
field={form.origin}
|
||||
label="Account origin"
|
||||
options={
|
||||
<>
|
||||
<option value="">Local or remote</option>
|
||||
<option value="local">Local only</option>
|
||||
<option value="remote">Remote only</option>
|
||||
</>
|
||||
}
|
||||
></Select>
|
||||
<TextInput
|
||||
field={form.email}
|
||||
label={"(Optional) email address (local accounts only)"}
|
||||
placeholder={"someone@example.org"}
|
||||
// Get email validation for free.
|
||||
{...{type: "email"}}
|
||||
/>
|
||||
<TextInput
|
||||
field={form.ip}
|
||||
label={"(Optional) IP address (local accounts only)"}
|
||||
placeholder={"198.51.100.0"}
|
||||
/>
|
||||
<Select
|
||||
field={form.status}
|
||||
label="Account status"
|
||||
options={
|
||||
<>
|
||||
<option value="">Any</option>
|
||||
<option value="pending">Pending only</option>
|
||||
<option value="disabled">Disabled only</option>
|
||||
<option value="suspended">Suspended only</option>
|
||||
</>
|
||||
}
|
||||
></Select>
|
||||
<MutationButton
|
||||
disabled={false}
|
||||
label={"Search"}
|
||||
result={searchRes}
|
||||
/>
|
||||
</form>
|
||||
<AccountList
|
||||
isLoading={searchRes.isLoading}
|
||||
isSuccess={searchRes.isSuccess}
|
||||
data={searchRes.data}
|
||||
isError={searchRes.isError}
|
||||
error={searchRes.error}
|
||||
emptyMessage="No accounts found that match your query"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,262 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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/>.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useLocation, useParams, useSearch } from "wouter";
|
||||
|
||||
import { useTextInput, useBoolInput } from "../../../lib/form";
|
||||
|
||||
import useFormSubmit from "../../../lib/form/submit";
|
||||
|
||||
import { TextInput, Checkbox, TextArea } from "../../../components/form/inputs";
|
||||
|
||||
import Loading from "../../../components/loading";
|
||||
import BackButton from "../../../components/back-button";
|
||||
import MutationButton from "../../../components/form/mutation-button";
|
||||
|
||||
import { useDomainAllowsQuery, useDomainBlocksQuery } from "../../../lib/query/admin/domain-permissions/get";
|
||||
import { useAddDomainAllowMutation, useAddDomainBlockMutation, useRemoveDomainAllowMutation, useRemoveDomainBlockMutation } from "../../../lib/query/admin/domain-permissions/update";
|
||||
import { DomainPerm, PermType } from "../../../lib/types/domain-permission";
|
||||
import { NoArg } from "../../../lib/types/query";
|
||||
import { Error } from "../../../components/error";
|
||||
import { useBaseUrl } from "../../../lib/navigation/util";
|
||||
|
||||
export default function DomainPermDetail() {
|
||||
const baseUrl = useBaseUrl();
|
||||
|
||||
// Parse perm type from routing params.
|
||||
let params = useParams();
|
||||
if (params.permType !== "blocks" && params.permType !== "allows") {
|
||||
throw "unrecognized perm type " + params.permType;
|
||||
}
|
||||
const permType = params.permType.slice(0, -1) as PermType;
|
||||
|
||||
const { data: domainBlocks = {}, isLoading: isLoadingDomainBlocks } = useDomainBlocksQuery(NoArg, { skip: permType !== "block" });
|
||||
const { data: domainAllows = {}, isLoading: isLoadingDomainAllows } = useDomainAllowsQuery(NoArg, { skip: permType !== "allow" });
|
||||
|
||||
let isLoading;
|
||||
switch (permType) {
|
||||
case "block":
|
||||
isLoading = isLoadingDomainBlocks;
|
||||
break;
|
||||
case "allow":
|
||||
isLoading = isLoadingDomainAllows;
|
||||
break;
|
||||
default:
|
||||
throw "perm type unknown";
|
||||
}
|
||||
|
||||
// Parse domain from routing params.
|
||||
let domain = params.domain ?? "unknown";
|
||||
|
||||
const search = useSearch();
|
||||
if (domain === "view") {
|
||||
// Retrieve domain from form field submission.
|
||||
const searchParams = new URLSearchParams(search);
|
||||
const searchDomain = searchParams.get("domain");
|
||||
if (!searchDomain) {
|
||||
throw "empty view domain";
|
||||
}
|
||||
|
||||
domain = searchDomain;
|
||||
}
|
||||
|
||||
// Normalize / decode domain (it may be URL-encoded).
|
||||
domain = decodeURIComponent(domain);
|
||||
|
||||
// Check if we already have a perm of the desired type for this domain.
|
||||
const existingPerm: DomainPerm | undefined = useMemo(() => {
|
||||
if (permType == "block") {
|
||||
return domainBlocks[domain];
|
||||
} else {
|
||||
return domainAllows[domain];
|
||||
}
|
||||
}, [domainBlocks, domainAllows, domain, permType]);
|
||||
|
||||
let infoContent: React.JSX.Element;
|
||||
|
||||
if (isLoading) {
|
||||
infoContent = <Loading />;
|
||||
} else if (existingPerm == undefined) {
|
||||
infoContent = <span>No stored {permType} 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 permissions 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 className="text-cutoff"><BackButton to={`~${baseUrl}/${permType}s`}/> Domain {permType} for: <span title={domain}>{domain}</span></h1>
|
||||
{infoContent}
|
||||
<DomainPermForm
|
||||
defaultDomain={domain}
|
||||
perm={existingPerm}
|
||||
permType={permType}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DomainPermFormProps {
|
||||
defaultDomain: string;
|
||||
perm?: DomainPerm;
|
||||
permType: PermType;
|
||||
}
|
||||
|
||||
function DomainPermForm({ defaultDomain, perm, permType }: DomainPermFormProps) {
|
||||
const isExistingPerm = perm !== undefined;
|
||||
const disabledForm = isExistingPerm
|
||||
? {
|
||||
disabled: true,
|
||||
title: "Domain permissions currently cannot be edited."
|
||||
}
|
||||
: {
|
||||
disabled: false,
|
||||
title: "",
|
||||
};
|
||||
|
||||
const form = {
|
||||
domain: useTextInput("domain", { source: perm, defaultValue: defaultDomain }),
|
||||
obfuscate: useBoolInput("obfuscate", { source: perm }),
|
||||
commentPrivate: useTextInput("private_comment", { source: perm }),
|
||||
commentPublic: useTextInput("public_comment", { source: perm })
|
||||
};
|
||||
|
||||
// Check which perm type we're meant to be handling
|
||||
// here, and use appropriate mutations and results.
|
||||
// We can't call these hooks conditionally because
|
||||
// react is like "weh" (mood), but we can decide
|
||||
// which ones to use conditionally.
|
||||
const [ addBlock, addBlockResult ] = useAddDomainBlockMutation();
|
||||
const [ removeBlock, removeBlockResult] = useRemoveDomainBlockMutation({ fixedCacheKey: perm?.id });
|
||||
const [ addAllow, addAllowResult ] = useAddDomainAllowMutation();
|
||||
const [ removeAllow, removeAllowResult ] = useRemoveDomainAllowMutation({ fixedCacheKey: perm?.id });
|
||||
|
||||
const [
|
||||
addTrigger,
|
||||
addResult,
|
||||
removeTrigger,
|
||||
removeResult,
|
||||
] = useMemo(() => {
|
||||
return permType == "block"
|
||||
? [
|
||||
addBlock,
|
||||
addBlockResult,
|
||||
removeBlock,
|
||||
removeBlockResult,
|
||||
]
|
||||
: [
|
||||
addAllow,
|
||||
addAllowResult,
|
||||
removeAllow,
|
||||
removeAllowResult,
|
||||
];
|
||||
}, [permType,
|
||||
addBlock, addBlockResult, removeBlock, removeBlockResult,
|
||||
addAllow, addAllowResult, removeAllow, removeAllowResult,
|
||||
]);
|
||||
|
||||
// Use appropriate submission params for this permType.
|
||||
const [submitForm, submitFormResult] = useFormSubmit(form, [addTrigger, addResult], { changedOnly: false });
|
||||
|
||||
// Uppercase first letter of given permType.
|
||||
const permTypeUpper = useMemo(() => {
|
||||
return permType.charAt(0).toUpperCase() + permType.slice(1);
|
||||
}, [permType]);
|
||||
|
||||
const [location, setLocation] = useLocation();
|
||||
|
||||
function verifyUrlThenSubmit(e) {
|
||||
// Adding a new domain permissions happens on a url like
|
||||
// "/settings/admin/domain-permissions/:permType/domain.com",
|
||||
// but if domain input changes, that doesn't match anymore
|
||||
// and causes issues later on so, before submitting the form,
|
||||
// silently change url, and THEN submit.
|
||||
let correctUrl = `/${permType}s/${form.domain.value}`;
|
||||
if (location != correctUrl) {
|
||||
setLocation(correctUrl);
|
||||
}
|
||||
return submitForm(e);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={verifyUrlThenSubmit}>
|
||||
<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}
|
||||
/>
|
||||
|
||||
<div className="action-buttons row">
|
||||
<MutationButton
|
||||
label={permTypeUpper}
|
||||
result={submitFormResult}
|
||||
showError={false}
|
||||
{...disabledForm}
|
||||
/>
|
||||
|
||||
{
|
||||
isExistingPerm &&
|
||||
<MutationButton
|
||||
type="button"
|
||||
onClick={() => removeTrigger(perm.id?? "")}
|
||||
label="Remove"
|
||||
result={removeResult}
|
||||
className="button danger"
|
||||
showError={false}
|
||||
disabled={!isExistingPerm}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
<>
|
||||
{addResult.error && <Error error={addResult.error} />}
|
||||
{removeResult.error && <Error error={removeResult.error} />}
|
||||
</>
|
||||
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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/>.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
export default function ExportFormatTable() {
|
||||
return (
|
||||
<div className="export-format-table-wrapper">
|
||||
<table className="export-format-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th rowSpan={2} />
|
||||
<th colSpan={2}>Includes</th>
|
||||
<th colSpan={2}>Importable by</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Domain</th>
|
||||
<th>Public comment</th>
|
||||
<th>GoToSocial</th>
|
||||
<th>Mastodon</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Format name="Text" info={[true, false, true, false]} />
|
||||
<Format name="JSON" info={[true, true, true, false]} />
|
||||
<Format name="CSV" info={[true, true, true, true]} />
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Format({ name, info }) {
|
||||
return (
|
||||
<tr>
|
||||
<td><b>{name}</b></td>
|
||||
{info.map((b, key) => <td key={key} className="bool">{bool(b)}</td>)}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function bool(val) {
|
||||
return (
|
||||
<>
|
||||
<i className={`fa fa-${val ? "check" : "times"}`} aria-hidden="true"></i>
|
||||
<span className="sr-only">{val ? "Yes" : "No"}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
153
web/source/settings/views/moderation/domain-permissions/form.tsx
Normal file
153
web/source/settings/views/moderation/domain-permissions/form.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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/>.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useExportDomainListMutation } from "../../../lib/query/admin/domain-permissions/export";
|
||||
import useFormSubmit from "../../../lib/form/submit";
|
||||
|
||||
import {
|
||||
RadioGroup,
|
||||
TextArea,
|
||||
Select,
|
||||
} from "../../../components/form/inputs";
|
||||
|
||||
import MutationButton from "../../../components/form/mutation-button";
|
||||
|
||||
import { Error } from "../../../components/error";
|
||||
import ExportFormatTable from "./export-format-table";
|
||||
|
||||
import type {
|
||||
FormSubmitFunction,
|
||||
FormSubmitResult,
|
||||
RadioFormInputHook,
|
||||
TextFormInputHook,
|
||||
} from "../../../lib/form/types";
|
||||
|
||||
export interface ImportExportFormProps {
|
||||
form: {
|
||||
domains: TextFormInputHook;
|
||||
exportType: TextFormInputHook;
|
||||
permType: RadioFormInputHook;
|
||||
};
|
||||
submitParse: FormSubmitFunction;
|
||||
parseResult: FormSubmitResult;
|
||||
}
|
||||
|
||||
export default function ImportExportForm({ form, submitParse, parseResult }: ImportExportFormProps) {
|
||||
const [submitExport, exportResult] = useFormSubmit(form, useExportDomainListMutation());
|
||||
|
||||
function fileChanged(e) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function (read) {
|
||||
const res = read.target?.result;
|
||||
if (typeof res === "string") {
|
||||
form.domains.value = res;
|
||||
submitParse();
|
||||
}
|
||||
};
|
||||
reader.readAsText(e.target.files[0]);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (exportResult.isSuccess) {
|
||||
form.domains.setter(exportResult.data);
|
||||
}
|
||||
/* eslint-disable-next-line react-hooks/exhaustive-deps */
|
||||
}, [exportResult]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Import / Export domain permissions</h1>
|
||||
<p>This page can be used to import and export lists of domain permissions.</p>
|
||||
<p>Exports can be done in various formats, with varying functionality and support in other software.</p>
|
||||
<p>Imports will automatically detect what format is being processed.</p>
|
||||
<ExportFormatTable />
|
||||
<div className="import-export">
|
||||
<TextArea
|
||||
field={form.domains}
|
||||
label="Domains"
|
||||
placeholder={`google.com\nfacebook.com`}
|
||||
rows={8}
|
||||
/>
|
||||
|
||||
<RadioGroup
|
||||
field={form.permType}
|
||||
/>
|
||||
|
||||
<div className="button-grid">
|
||||
<MutationButton
|
||||
label="Import"
|
||||
type="button"
|
||||
onClick={() => submitParse()}
|
||||
result={parseResult}
|
||||
showError={false}
|
||||
disabled={form.permType.value === undefined || form.permType.value.length === 0}
|
||||
/>
|
||||
<label className={`button with-icon${form.permType.value === undefined || form.permType.value.length === 0 ? " disabled" : ""}`}>
|
||||
<i className="fa fa-fw " aria-hidden="true" />
|
||||
Import file
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={fileChanged}
|
||||
accept="application/json,text/plain,text/csv"
|
||||
disabled={form.permType.value === undefined || form.permType.value.length === 0}
|
||||
/>
|
||||
</label>
|
||||
<b /> {/* grid filler */}
|
||||
<MutationButton
|
||||
label="Export"
|
||||
type="button"
|
||||
onClick={() => submitExport("export")}
|
||||
result={exportResult} showError={false}
|
||||
disabled={form.permType.value === undefined || form.permType.value.length === 0}
|
||||
/>
|
||||
<MutationButton
|
||||
label="Export to file"
|
||||
wrapperClassName="export-file-button"
|
||||
type="button"
|
||||
onClick={() => submitExport("export-file")}
|
||||
result={exportResult}
|
||||
showError={false}
|
||||
disabled={form.permType.value === undefined || form.permType.value.length === 0}
|
||||
/>
|
||||
<div className="export-file">
|
||||
<span>
|
||||
as
|
||||
</span>
|
||||
<Select
|
||||
field={form.exportType}
|
||||
options={<>
|
||||
<option value="plain">Text</option>
|
||||
<option value="json">JSON</option>
|
||||
<option value="csv">CSV</option>
|
||||
</>}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{parseResult.error && <Error error={parseResult.error} />}
|
||||
{exportResult.error && <Error error={exportResult.error} />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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/>.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { Switch, Route, Redirect, useLocation } from "wouter";
|
||||
import { useProcessDomainPermissionsMutation } from "../../../lib/query/admin/domain-permissions/process";
|
||||
import { useTextInput, useRadioInput } from "../../../lib/form";
|
||||
import useFormSubmit from "../../../lib/form/submit";
|
||||
import { ProcessImport } from "./process";
|
||||
import ImportExportForm from "./form";
|
||||
|
||||
export default function ImportExport() {
|
||||
const form = {
|
||||
domains: useTextInput("domains"),
|
||||
exportType: useTextInput("exportType", {
|
||||
defaultValue: "plain",
|
||||
dontReset: true,
|
||||
}),
|
||||
permType: useRadioInput("permType", {
|
||||
options: {
|
||||
block: "Domain blocks",
|
||||
allow: "Domain allows",
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
const [submitParse, parseResult] = useFormSubmit(form, useProcessDomainPermissionsMutation(), { changedOnly: false });
|
||||
const [_location, setLocation] = useLocation();
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Route path={"/process"}>
|
||||
{
|
||||
parseResult.isSuccess
|
||||
? (
|
||||
<>
|
||||
<h1>
|
||||
<span
|
||||
className="button"
|
||||
onClick={() => {
|
||||
parseResult.reset();
|
||||
setLocation("");
|
||||
}}
|
||||
>
|
||||
< back
|
||||
</span>
|
||||
Confirm import of domain {form.permType.value}s:
|
||||
</h1>
|
||||
<ProcessImport
|
||||
list={parseResult.data}
|
||||
permType={form.permType}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
: <Redirect to={""} />
|
||||
}
|
||||
</Route>
|
||||
<Route>
|
||||
{
|
||||
parseResult.isSuccess
|
||||
? <Redirect to={"/process"} />
|
||||
: <ImportExportForm
|
||||
form={form}
|
||||
submitParse={submitParse}
|
||||
parseResult={parseResult}
|
||||
/>
|
||||
}
|
||||
</Route>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,197 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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/>.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Link, useLocation, useParams } from "wouter";
|
||||
import { matchSorter } from "match-sorter";
|
||||
|
||||
import { useTextInput } from "../../../lib/form";
|
||||
|
||||
import { TextInput } from "../../../components/form/inputs";
|
||||
|
||||
import Loading from "../../../components/loading";
|
||||
import { useDomainAllowsQuery, useDomainBlocksQuery } from "../../../lib/query/admin/domain-permissions/get";
|
||||
import type { MappedDomainPerms, PermType } from "../../../lib/types/domain-permission";
|
||||
import { NoArg } from "../../../lib/types/query";
|
||||
|
||||
export default function DomainPermissionsOverview() {
|
||||
// Parse perm type from routing params.
|
||||
let params = useParams();
|
||||
if (params.permType !== "blocks" && params.permType !== "allows") {
|
||||
throw "unrecognized perm type " + params.permType;
|
||||
}
|
||||
const permType = params.permType.slice(0, -1) as PermType;
|
||||
|
||||
// Uppercase first letter of given permType.
|
||||
const permTypeUpper = useMemo(() => {
|
||||
return permType.charAt(0).toUpperCase() + permType.slice(1);
|
||||
}, [permType]);
|
||||
|
||||
// Fetch / wait for desired perms to load.
|
||||
const { data: blocks, isLoading: isLoadingBlocks } = useDomainBlocksQuery(NoArg, { skip: permType !== "block" });
|
||||
const { data: allows, isLoading: isLoadingAllows } = useDomainAllowsQuery(NoArg, { skip: permType !== "allow" });
|
||||
|
||||
let data: MappedDomainPerms | undefined;
|
||||
let isLoading: boolean;
|
||||
|
||||
if (permType == "block") {
|
||||
data = blocks;
|
||||
isLoading = isLoadingBlocks;
|
||||
} else {
|
||||
data = allows;
|
||||
isLoading = isLoadingAllows;
|
||||
}
|
||||
|
||||
if (isLoading || data === undefined) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Domain {permTypeUpper}s</h1>
|
||||
{ permType == "block" ? <BlockHelperText/> : <AllowHelperText/> }
|
||||
<DomainPermsList
|
||||
data={data}
|
||||
permType={permType}
|
||||
permTypeUpper={permTypeUpper}
|
||||
/>
|
||||
<Link to="/settings/admin/domain-permissions/import-export">
|
||||
Or use the bulk import/export interface
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface DomainPermsListProps {
|
||||
data: MappedDomainPerms;
|
||||
permType: PermType;
|
||||
permTypeUpper: string;
|
||||
}
|
||||
|
||||
function DomainPermsList({ data, permType, permTypeUpper }: DomainPermsListProps) {
|
||||
// Format perms into a list.
|
||||
const perms = useMemo(() => {
|
||||
return Object.values(data);
|
||||
}, [data]);
|
||||
|
||||
const [_location, setLocation] = useLocation();
|
||||
const filterField = useTextInput("filter");
|
||||
|
||||
function filterFormSubmit(e) {
|
||||
e.preventDefault();
|
||||
setLocation(`/${filter}`);
|
||||
}
|
||||
|
||||
const filter = filterField.value ?? "";
|
||||
const filteredPerms = useMemo(() => {
|
||||
return matchSorter(perms, filter, { keys: ["domain"] });
|
||||
}, [perms, filter]);
|
||||
const filtered = perms.length - filteredPerms.length;
|
||||
|
||||
const filterInfo = (
|
||||
<span>
|
||||
{perms.length} {permType}ed domain{perms.length != 1 ? "s" : ""} {filtered > 0 && `(${filtered} filtered by search)`}
|
||||
</span>
|
||||
);
|
||||
|
||||
const entries = filteredPerms.map((entry) => {
|
||||
return (
|
||||
<Link
|
||||
className="entry nounderline"
|
||||
key={entry.domain}
|
||||
to={`/${permType}s/${entry.domain}`}
|
||||
>
|
||||
<span id="domain">{entry.domain}</span>
|
||||
<span id="date">{new Date(entry.created_at ?? "").toLocaleString()}</span>
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="domain-permissions-list">
|
||||
<form className="filter" role="search" onSubmit={filterFormSubmit}>
|
||||
<TextInput
|
||||
field={filterField}
|
||||
placeholder="example.org"
|
||||
label={`Search or add domain ${permType}`}
|
||||
/>
|
||||
<Link
|
||||
className="button"
|
||||
to={`/${permType}s/${filter}`}
|
||||
>
|
||||
{permTypeUpper} {filter}
|
||||
</Link>
|
||||
</form>
|
||||
<div>
|
||||
{filterInfo}
|
||||
<div className="list">
|
||||
<div className="entries scrolling">
|
||||
{entries}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BlockHelperText() {
|
||||
return (
|
||||
<p>
|
||||
Blocking a domain blocks interaction between your instance, and all current and future accounts on
|
||||
instance(s) running on the blocked domain. Stored content will be removed, and no more data is sent to
|
||||
the remote server. This extends to all subdomains as well, so blocking 'example.com' also blocks 'social.example.com'.
|
||||
<br/>
|
||||
<a
|
||||
href="https://docs.gotosocial.org/en/latest/admin/domain_blocks/"
|
||||
target="_blank"
|
||||
className="docslink"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more about domain blocks (opens in a new tab)
|
||||
</a>
|
||||
<br/>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function AllowHelperText() {
|
||||
return (
|
||||
<p>
|
||||
Allowing a domain explicitly allows instance(s) running on that domain to interact with your instance.
|
||||
If you're running in allowlist mode, this is how you "allow" instances through.
|
||||
If you're running in blocklist mode (the default federation mode), you can use explicit domain allows
|
||||
to override domain blocks. In blocklist mode, explicitly allowed instances will be able to interact with
|
||||
your instance regardless of any domain blocks in place. This extends to all subdomains as well, so allowing
|
||||
'example.com' also allows 'social.example.com'. This is useful when you're importing a block list but
|
||||
there are some domains on the list you don't want to block: just create an explicit allow for those domains
|
||||
before importing the list.
|
||||
<br/>
|
||||
<a
|
||||
href="https://docs.gotosocial.org/en/latest/admin/federation_modes/"
|
||||
target="_blank"
|
||||
className="docslink"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more about federation modes (opens in a new tab)
|
||||
</a>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,400 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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/>.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { memo, useMemo, useCallback, useEffect } from "react";
|
||||
|
||||
import { isValidDomainPermission, hasBetterScope } from "../../../lib/util/domain-permission";
|
||||
|
||||
import {
|
||||
useTextInput,
|
||||
useBoolInput,
|
||||
useRadioInput,
|
||||
useCheckListInput,
|
||||
} from "../../../lib/form";
|
||||
|
||||
import {
|
||||
Select,
|
||||
TextArea,
|
||||
RadioGroup,
|
||||
Checkbox,
|
||||
TextInput,
|
||||
} from "../../../components/form/inputs";
|
||||
|
||||
import useFormSubmit from "../../../lib/form/submit";
|
||||
|
||||
import CheckList from "../../../components/check-list";
|
||||
import MutationButton from "../../../components/form/mutation-button";
|
||||
import FormWithData from "../../../lib/form/form-with-data";
|
||||
|
||||
import { useImportDomainPermsMutation } from "../../../lib/query/admin/domain-permissions/import";
|
||||
import {
|
||||
useDomainAllowsQuery,
|
||||
useDomainBlocksQuery
|
||||
} from "../../../lib/query/admin/domain-permissions/get";
|
||||
|
||||
import type { DomainPerm, MappedDomainPerms } from "../../../lib/types/domain-permission";
|
||||
import type { ChecklistInputHook, RadioFormInputHook } from "../../../lib/form/types";
|
||||
|
||||
export interface ProcessImportProps {
|
||||
list: DomainPerm[],
|
||||
permType: RadioFormInputHook,
|
||||
}
|
||||
|
||||
export const ProcessImport = memo(
|
||||
function ProcessImport({ list, permType }: ProcessImportProps) {
|
||||
return (
|
||||
<FormWithData
|
||||
dataQuery={permType.value == "allow"
|
||||
? useDomainAllowsQuery
|
||||
: useDomainBlocksQuery
|
||||
}
|
||||
DataForm={ImportList}
|
||||
{...{ list, permType }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export interface ImportListProps {
|
||||
list: Array<DomainPerm>,
|
||||
data: MappedDomainPerms,
|
||||
permType: RadioFormInputHook,
|
||||
}
|
||||
|
||||
function ImportList({ list, data: domainPerms, permType }: ImportListProps) {
|
||||
const hasComment = useMemo(() => {
|
||||
let hasPublic = false;
|
||||
let hasPrivate = false;
|
||||
|
||||
list.some((entry) => {
|
||||
if (entry.public_comment) {
|
||||
hasPublic = true;
|
||||
}
|
||||
|
||||
if (entry.private_comment) {
|
||||
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" });
|
||||
|
||||
const form = {
|
||||
domains: useCheckListInput("domains", { entries: list }), // DomainPerm is actually also a Checkable.
|
||||
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"
|
||||
}
|
||||
}),
|
||||
permType: permType,
|
||||
};
|
||||
|
||||
const [importDomains, importResult] = useFormSubmit(form, useImportDomainPermsMutation(), { changedOnly: false });
|
||||
|
||||
return (
|
||||
<>
|
||||
<form
|
||||
onSubmit={importDomains}
|
||||
className="domain-perm-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>
|
||||
</>
|
||||
} />
|
||||
}
|
||||
|
||||
<div className="checkbox-list-wrapper">
|
||||
<DomainCheckList
|
||||
field={form.domains}
|
||||
domainPerms={domainPerms}
|
||||
commentType={showComment.value as "public_comment" | "private_comment"}
|
||||
permType={form.permType}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
disabled={false}
|
||||
result={importResult}
|
||||
/>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface DomainCheckListProps {
|
||||
field: ChecklistInputHook,
|
||||
domainPerms: MappedDomainPerms,
|
||||
commentType: "public_comment" | "private_comment",
|
||||
permType: RadioFormInputHook,
|
||||
}
|
||||
|
||||
function DomainCheckList({ field, domainPerms, commentType, permType }: DomainCheckListProps) {
|
||||
const getExtraProps = useCallback((entry: DomainPerm) => {
|
||||
return {
|
||||
comment: entry[commentType],
|
||||
alreadyExists: entry.domain in domainPerms,
|
||||
permType: permType,
|
||||
};
|
||||
}, [domainPerms, commentType, permType]);
|
||||
|
||||
const entriesWithSuggestions = useMemo(() => {
|
||||
const fieldValue = (field.value ?? {}) as { [k: string]: DomainPerm; };
|
||||
return Object.values(fieldValue).filter((entry) => entry.suggest);
|
||||
}, [field.value]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CheckList
|
||||
field={field as ChecklistInputHook}
|
||||
header={<>
|
||||
<b>Domain</b>
|
||||
<b>
|
||||
{commentType == "public_comment" && "Public comment"}
|
||||
{commentType == "private_comment" && "Private comment"}
|
||||
</b>
|
||||
</>}
|
||||
EntryComponent={DomainEntry}
|
||||
getExtraProps={getExtraProps}
|
||||
/>
|
||||
<UpdateHint
|
||||
entries={entriesWithSuggestions}
|
||||
updateEntry={field.onChange}
|
||||
updateMultiple={field.updateMultiple}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface UpdateHintProps {
|
||||
entries,
|
||||
updateEntry,
|
||||
updateMultiple,
|
||||
}
|
||||
|
||||
const UpdateHint = memo(
|
||||
function UpdateHint({ entries, updateEntry, updateMultiple }: UpdateHintProps) {
|
||||
if (entries.length == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function changeAll() {
|
||||
updateMultiple(
|
||||
entries.map((entry) => [entry.key, { domain: entry.suggest, suggest: null }])
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="update-hints">
|
||||
<p>
|
||||
{entries.length} {entries.length == 1 ? "entry uses" : "entries use"} a specific subdomain,
|
||||
which you might want to change to the main domain, as that includes all it's (future) subdomains.
|
||||
</p>
|
||||
<div className="hints">
|
||||
{entries.map((entry) => (
|
||||
<UpdateableEntry key={entry.key} entry={entry} updateEntry={updateEntry} />
|
||||
))}
|
||||
</div>
|
||||
{entries.length > 0 && <a onClick={changeAll}>change all</a>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
interface UpdateableEntryProps {
|
||||
entry,
|
||||
updateEntry,
|
||||
}
|
||||
|
||||
const UpdateableEntry = memo(
|
||||
function UpdateableEntry({ entry, updateEntry }: UpdateableEntryProps) {
|
||||
return (
|
||||
<>
|
||||
<span className="text-cutoff">{entry.domain}</span>
|
||||
<i className="fa fa-long-arrow-right" aria-hidden="true"></i>
|
||||
<span>{entry.suggest}</span>
|
||||
<a role="button" onClick={() =>
|
||||
updateEntry(entry.key, { domain: entry.suggest, suggest: null })
|
||||
}>change</a>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
function domainValidationError(isValid) {
|
||||
return isValid ? "" : "Invalid domain";
|
||||
}
|
||||
|
||||
interface DomainEntryProps {
|
||||
entry;
|
||||
onChange;
|
||||
extraProps: {
|
||||
alreadyExists: boolean;
|
||||
comment: string;
|
||||
permType: RadioFormInputHook;
|
||||
};
|
||||
}
|
||||
|
||||
function DomainEntry({ entry, onChange, extraProps: { alreadyExists, comment, permType } }: DomainEntryProps) {
|
||||
const domainField = useTextInput("domain", {
|
||||
defaultValue: entry.domain,
|
||||
showValidation: entry.checked,
|
||||
initValidation: domainValidationError(entry.valid),
|
||||
validator: (value) => domainValidationError(isValidDomainPermission(value))
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (entry.valid != domainField.valid) {
|
||||
onChange({ valid: domainField.valid });
|
||||
}
|
||||
}, [onChange, entry.valid, domainField.valid]);
|
||||
|
||||
useEffect(() => {
|
||||
if (entry.domain != domainField.value) {
|
||||
domainField.setter(entry.domain);
|
||||
}
|
||||
// domainField.setter is enough, eslint wants domainField
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [entry.domain, domainField.setter]);
|
||||
|
||||
useEffect(() => {
|
||||
onChange({ suggest: hasBetterScope(domainField.value ?? "") });
|
||||
// only need this update if it's the entry.checked that updated, not onChange
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [domainField.value]);
|
||||
|
||||
function clickIcon(e) {
|
||||
if (entry.suggest) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
domainField.setter(entry.suggest);
|
||||
onChange({ domain: entry.suggest, checked: true });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="domain-input">
|
||||
<TextInput
|
||||
field={domainField}
|
||||
onChange={(e) => {
|
||||
domainField.onChange(e);
|
||||
onChange({ domain: e.target.value, checked: true });
|
||||
}}
|
||||
/>
|
||||
<span id="icon" onClick={clickIcon}>
|
||||
<DomainEntryIcon
|
||||
alreadyExists={alreadyExists}
|
||||
suggestion={entry.suggest}
|
||||
permTypeString={permType.value?? ""}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<p>{comment}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface DomainEntryIconProps {
|
||||
alreadyExists: boolean;
|
||||
suggestion: string;
|
||||
permTypeString: string;
|
||||
}
|
||||
|
||||
function DomainEntryIcon({ alreadyExists, suggestion, permTypeString }: DomainEntryIconProps) {
|
||||
let icon;
|
||||
let text;
|
||||
|
||||
if (suggestion) {
|
||||
icon = "fa-info-circle suggest-changes";
|
||||
text = `Entry targets a specific subdomain, consider changing it to '${suggestion}'.`;
|
||||
} else if (alreadyExists) {
|
||||
icon = "fa-history permission-already-exists";
|
||||
text = `Domain ${permTypeString} already exists.`;
|
||||
}
|
||||
|
||||
if (!icon) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<i className={`fa fa-fw ${icon}`} aria-hidden="true" title={text}></i>
|
||||
<span className="sr-only">{text}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
243
web/source/settings/views/moderation/reports/detail.tsx
Normal file
243
web/source/settings/views/moderation/reports/detail.tsx
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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/>.
|
||||
*/
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useParams } from "wouter";
|
||||
import FormWithData from "../../../lib/form/form-with-data";
|
||||
import BackButton from "../../../components/back-button";
|
||||
import { useValue, useTextInput } from "../../../lib/form";
|
||||
import useFormSubmit from "../../../lib/form/submit";
|
||||
import { TextArea } from "../../../components/form/inputs";
|
||||
import MutationButton from "../../../components/form/mutation-button";
|
||||
import Username from "./username";
|
||||
import { useGetReportQuery, useResolveReportMutation } from "../../../lib/query/admin/reports";
|
||||
import { useBaseUrl } from "../../../lib/navigation/util";
|
||||
|
||||
export default function ReportDetail({ }) {
|
||||
const baseUrl = useBaseUrl();
|
||||
const params = useParams();
|
||||
|
||||
return (
|
||||
<div className="reports">
|
||||
<h1><BackButton to={`~${baseUrl}`}/> Report Details</h1>
|
||||
<FormWithData
|
||||
dataQuery={useGetReportQuery}
|
||||
queryArg={params.reportId}
|
||||
DataForm={ReportDetailForm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportDetailForm({ data: report }) {
|
||||
const from = report.account;
|
||||
const target = report.target_account;
|
||||
|
||||
return (
|
||||
<div className="report detail">
|
||||
<div className="usernames">
|
||||
<Username user={from} /> reported <Username user={target} />
|
||||
</div>
|
||||
|
||||
{report.action_taken &&
|
||||
<div className="info">
|
||||
<h3>Resolved by @{report.action_taken_by_account.account.acct}</h3>
|
||||
<span className="timestamp">at {new Date(report.action_taken_at).toLocaleString()}</span>
|
||||
<br />
|
||||
<b>Comment: </b><span>{report.action_taken_comment}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className="info-block">
|
||||
<h3>Report info:</h3>
|
||||
<div className="details">
|
||||
<b>Created: </b>
|
||||
<span>{new Date(report.created_at).toLocaleString()}</span>
|
||||
|
||||
<b>Forwarded: </b> <span>{report.forwarded ? "Yes" : "No"}</span>
|
||||
<b>Category: </b> <span>{report.category}</span>
|
||||
|
||||
<b>Reason: </b>
|
||||
{report.comment.length > 0
|
||||
? <p>{report.comment}</p>
|
||||
: <i className="no-comment">none provided</i>
|
||||
}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!report.action_taken && <ReportActionForm report={report} />}
|
||||
|
||||
{
|
||||
report.statuses.length > 0 &&
|
||||
<div className="info-block">
|
||||
<h3>Reported toots ({report.statuses.length}):</h3>
|
||||
<div className="reported-toots">
|
||||
{report.statuses.map((status) => (
|
||||
<ReportedToot key={status.id} toot={status} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportActionForm({ report }) {
|
||||
const form = {
|
||||
id: useValue("id", report.id),
|
||||
comment: useTextInput("action_taken_comment")
|
||||
};
|
||||
|
||||
const [submit, result] = useFormSubmit(form, useResolveReportMutation(), { changedOnly: false });
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="info-block">
|
||||
<h3>Resolving this report</h3>
|
||||
<p>
|
||||
An optional comment can be included while resolving this report.
|
||||
Useful for providing an explanation about what action was taken (if any) before the report was marked as resolved.<br />
|
||||
<b>This will be visible to the user that created the report!</b>
|
||||
</p>
|
||||
<TextArea
|
||||
field={form.comment}
|
||||
label="Comment"
|
||||
/>
|
||||
<MutationButton
|
||||
disabled={false}
|
||||
label="Resolve"
|
||||
result={result}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportedToot({ toot }) {
|
||||
const account = toot.account;
|
||||
|
||||
return (
|
||||
<article className="status expanded">
|
||||
<header className="status-header">
|
||||
<address>
|
||||
<a style={{margin: 0}}>
|
||||
<img className="avatar" src={account.avatar} alt="" />
|
||||
<dl className="author-strap">
|
||||
<dt className="sr-only">Display name</dt>
|
||||
<dd className="displayname text-cutoff">
|
||||
{account.display_name.trim().length > 0 ? account.display_name : account.username}
|
||||
</dd>
|
||||
<dt className="sr-only">Username</dt>
|
||||
<dd className="username text-cutoff">@{account.username}</dd>
|
||||
</dl>
|
||||
</a>
|
||||
</address>
|
||||
</header>
|
||||
<section className="status-body">
|
||||
<div className="text">
|
||||
<div className="content">
|
||||
{toot.spoiler_text?.length > 0
|
||||
? <TootCW content={toot.content} note={toot.spoiler_text} />
|
||||
: toot.content
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
{toot.media_attachments?.length > 0 &&
|
||||
<TootMedia media={toot.media_attachments} sensitive={toot.sensitive} />
|
||||
}
|
||||
</section>
|
||||
<aside className="status-info">
|
||||
<dl className="status-stats">
|
||||
<div className="stats-grouping">
|
||||
<div className="stats-item published-at text-cutoff">
|
||||
<dt className="sr-only">Published</dt>
|
||||
<dd>
|
||||
<time dateTime={toot.created_at}>{new Date(toot.created_at).toLocaleString()}</time>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</dl>
|
||||
</aside>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function TootCW({ note, content }) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
function toggleVisible() {
|
||||
setVisible(!visible);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="spoiler">
|
||||
<span>{note}</span>
|
||||
<label className="button spoiler-label" onClick={toggleVisible}>Show {visible ? "less" : "more"}</label>
|
||||
</div>
|
||||
{visible && content}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TootMedia({ media, sensitive }) {
|
||||
let classes = (media.length % 2 == 0) ? "even" : "odd";
|
||||
if (media.length == 1) {
|
||||
classes += " single";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`media photoswipe-gallery ${classes}`}>
|
||||
{media.map((m) => (
|
||||
<div key={m.id} className="media-wrapper">
|
||||
{sensitive && <>
|
||||
<input id={`sensitiveMedia-${m.id}`} type="checkbox" className="sensitive-checkbox hidden" />
|
||||
<div className="sensitive">
|
||||
<div className="open">
|
||||
<label htmlFor={`sensitiveMedia-${m.id}`} className="button" role="button" tabIndex={0}>
|
||||
<i className="fa fa-eye-slash" title="Hide sensitive media"></i>
|
||||
</label>
|
||||
</div>
|
||||
<div className="closed" title={m.description}>
|
||||
<label htmlFor={`sensitiveMedia-${m.id}`} className="button" role="button" tabIndex={0}>
|
||||
Show sensitive media
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</>}
|
||||
<a
|
||||
href={m.url}
|
||||
title={m.description}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
data-cropped="true"
|
||||
data-pswp-width={`${m.meta?.original.width}px`}
|
||||
data-pswp-height={`${m.meta?.original.height}px`}
|
||||
>
|
||||
<img
|
||||
alt={m.description}
|
||||
src={m.url}
|
||||
// thumb={m.preview_url}
|
||||
sizes={m.meta?.original}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
web/source/settings/views/moderation/reports/overview.tsx
Normal file
99
web/source/settings/views/moderation/reports/overview.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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/>.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Link } from "wouter";
|
||||
|
||||
import FormWithData from "../../../lib/form/form-with-data";
|
||||
|
||||
import Username from "./username";
|
||||
import { useListReportsQuery } from "../../../lib/query/admin/reports";
|
||||
|
||||
export function ReportOverview({ }) {
|
||||
return (
|
||||
<FormWithData
|
||||
dataQuery={useListReportsQuery}
|
||||
DataForm={ReportsList}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportsList({ data: reports }) {
|
||||
return (
|
||||
<div className="reports">
|
||||
<div className="form-section-docs">
|
||||
<h1>Reports</h1>
|
||||
<p>
|
||||
Here you can view and resolve reports made to your
|
||||
instance, originating from local and remote users.
|
||||
</p>
|
||||
<a
|
||||
href="https://docs.gotosocial.org/en/latest/admin/settings/#reports"
|
||||
target="_blank"
|
||||
className="docslink"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more about this (opens in a new tab)
|
||||
</a>
|
||||
</div>
|
||||
<div className="list">
|
||||
{reports.map((report) => (
|
||||
<ReportEntry key={report.id} report={report} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportEntry({ report }) {
|
||||
const from = report.account;
|
||||
const target = report.target_account;
|
||||
|
||||
let comment = report.comment.length > 200
|
||||
? report.comment.slice(0, 200) + "..."
|
||||
: report.comment;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/${report.id}`}
|
||||
className="nounderline"
|
||||
>
|
||||
<div className={`report entry${report.action_taken ? " resolved" : ""}`}>
|
||||
<div className="byline">
|
||||
<div className="usernames">
|
||||
<Username user={from} link={false} /> reported <Username user={target} link={false} />
|
||||
</div>
|
||||
<h3 className="report-status">
|
||||
{report.action_taken ? "Resolved" : "Open"}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="details">
|
||||
<b>Created: </b>
|
||||
<span>{new Date(report.created_at).toLocaleString()}</span>
|
||||
|
||||
<b>Reason: </b>
|
||||
{comment.length > 0
|
||||
? <p>{comment}</p>
|
||||
: <i className="no-comment">none provided</i>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
54
web/source/settings/views/moderation/reports/username.tsx
Normal file
54
web/source/settings/views/moderation/reports/username.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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/>.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Link } from "wouter";
|
||||
|
||||
export default function Username({ user, link = true }) {
|
||||
let className = "user";
|
||||
let isLocal = user.domain == null;
|
||||
|
||||
if (user.suspended) {
|
||||
className += " suspended";
|
||||
}
|
||||
|
||||
if (isLocal) {
|
||||
className += " local";
|
||||
}
|
||||
|
||||
let icon = isLocal
|
||||
? { fa: "fa-home", info: "Local user" }
|
||||
: { fa: "fa-external-link-square", info: "Remote user" };
|
||||
|
||||
let Element: any = "div";
|
||||
let href: any = null;
|
||||
|
||||
if (link) {
|
||||
Element = Link;
|
||||
href = `/settings/admin/accounts/${user.id}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<Element className={className} to={href}>
|
||||
<span className="acct">@{user.account.acct}</span>
|
||||
<i className={`fa fa-fw ${icon.fa}`} aria-hidden="true" title={icon.info} />
|
||||
<span className="sr-only">{icon.info}</span>
|
||||
</Element>
|
||||
);
|
||||
}
|
||||
201
web/source/settings/views/moderation/routes.tsx
Normal file
201
web/source/settings/views/moderation/routes.tsx
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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/>.
|
||||
*/
|
||||
|
||||
import { MenuItem } from "../../lib/navigation/menu";
|
||||
import React from "react";
|
||||
import { BaseUrlContext, useBaseUrl } from "../../lib/navigation/util";
|
||||
import { Redirect, Route, Router, Switch } from "wouter";
|
||||
import AccountsOverview from "./accounts";
|
||||
import AccountsPending from "./accounts/pending";
|
||||
import AccountDetail from "./accounts/detail";
|
||||
import { ReportOverview } from "./reports/overview";
|
||||
import DomainPermissionsOverview from "./domain-permissions/overview";
|
||||
import DomainPermDetail from "./domain-permissions/detail";
|
||||
import ImportExport from "./domain-permissions/import-export";
|
||||
import ReportDetail from "./reports/detail";
|
||||
|
||||
/*
|
||||
EXPORTED COMPONENTS
|
||||
*/
|
||||
|
||||
/**
|
||||
* Moderation menu. Reports, accounts,
|
||||
* domain permissions import + export.
|
||||
*/
|
||||
export function ModerationMenu() {
|
||||
return (
|
||||
<MenuItem
|
||||
name="Moderation"
|
||||
itemUrl="moderation"
|
||||
defaultChild="reports"
|
||||
permissions={["moderator"]}
|
||||
>
|
||||
<ModerationReportsMenu />
|
||||
<ModerationAccountsMenu />
|
||||
<ModerationDomainPermsMenu />
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moderation router. Reports, accounts,
|
||||
* domain permissions import + export.
|
||||
*/
|
||||
export function ModerationRouter() {
|
||||
const parentUrl = useBaseUrl();
|
||||
const thisBase = "/moderation";
|
||||
const absBase = parentUrl + thisBase;
|
||||
|
||||
return (
|
||||
<BaseUrlContext.Provider value={absBase}>
|
||||
<Router base={thisBase}>
|
||||
<ModerationReportsRouter />
|
||||
<ModerationAccountsRouter />
|
||||
<ModerationDomainPermsRouter />
|
||||
</Router>
|
||||
</BaseUrlContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
INTERNAL COMPONENTS
|
||||
*/
|
||||
|
||||
/*
|
||||
MENUS
|
||||
*/
|
||||
|
||||
function ModerationReportsMenu() {
|
||||
return (
|
||||
<MenuItem
|
||||
name="Reports"
|
||||
itemUrl="reports"
|
||||
icon="fa-flag"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ModerationAccountsMenu() {
|
||||
return (
|
||||
<MenuItem
|
||||
name="Accounts"
|
||||
itemUrl="accounts"
|
||||
defaultChild="overview"
|
||||
icon="fa-users"
|
||||
>
|
||||
<MenuItem
|
||||
name="Overview"
|
||||
itemUrl="overview"
|
||||
icon="fa-list"
|
||||
/>
|
||||
<MenuItem
|
||||
name="Pending"
|
||||
itemUrl="pending"
|
||||
icon="fa-question"
|
||||
/>
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function ModerationDomainPermsMenu() {
|
||||
return (
|
||||
<MenuItem
|
||||
name="Domain Permissions"
|
||||
itemUrl="domain-permissions"
|
||||
defaultChild="blocks"
|
||||
icon="fa-hubzilla"
|
||||
>
|
||||
<MenuItem
|
||||
name="Blocks"
|
||||
itemUrl="blocks"
|
||||
icon="fa-close"
|
||||
/>
|
||||
<MenuItem
|
||||
name="Allows"
|
||||
itemUrl="allows"
|
||||
icon="fa-check"
|
||||
/>
|
||||
<MenuItem
|
||||
name="Import/Export"
|
||||
itemUrl="import-export"
|
||||
icon="fa-floppy-o"
|
||||
/>
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
ROUTERS
|
||||
*/
|
||||
|
||||
function ModerationReportsRouter() {
|
||||
const parentUrl = useBaseUrl();
|
||||
const thisBase = "/reports";
|
||||
const absBase = parentUrl + thisBase;
|
||||
|
||||
return (
|
||||
<BaseUrlContext.Provider value={absBase}>
|
||||
<Router base={thisBase}>
|
||||
<Switch>
|
||||
<Route path={"/:reportId"} component={ReportDetail} />
|
||||
<Route component={ReportOverview}/>
|
||||
</Switch>
|
||||
</Router>
|
||||
</BaseUrlContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function ModerationAccountsRouter() {
|
||||
const parentUrl = useBaseUrl();
|
||||
const thisBase = "/accounts";
|
||||
const absBase = parentUrl + thisBase;
|
||||
|
||||
return (
|
||||
<BaseUrlContext.Provider value={absBase}>
|
||||
<Router base={thisBase}>
|
||||
<Switch>
|
||||
<Route path="/overview" component={AccountsOverview}/>
|
||||
<Route path="/pending" component={AccountsPending}/>
|
||||
<Route path="/:accountID" component={AccountDetail}/>
|
||||
<Route><Redirect to="/overview"/></Route>
|
||||
</Switch>
|
||||
</Router>
|
||||
</BaseUrlContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function ModerationDomainPermsRouter() {
|
||||
const parentUrl = useBaseUrl();
|
||||
const thisBase = "/domain-permissions";
|
||||
const absBase = parentUrl + thisBase;
|
||||
|
||||
return (
|
||||
<BaseUrlContext.Provider value={absBase}>
|
||||
<Router base={thisBase}>
|
||||
<Switch>
|
||||
<Route path="/import-export" component={ImportExport} />
|
||||
<Route path="/process" component={ImportExport} />
|
||||
<Route path="/:permType/:domain" component={DomainPermDetail} />
|
||||
<Route path="/:permType" component={DomainPermissionsOverview} />
|
||||
<Route><Redirect to="/blocks"/></Route>
|
||||
</Switch>
|
||||
</Router>
|
||||
</BaseUrlContext.Provider>
|
||||
);
|
||||
}
|
||||
208
web/source/settings/views/user/migration.tsx
Normal file
208
web/source/settings/views/user/migration.tsx
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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/>.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
import FormWithData from "../../lib/form/form-with-data";
|
||||
|
||||
import { useVerifyCredentialsQuery } from "../../lib/query/oauth";
|
||||
import { useArrayInput, useTextInput } from "../../lib/form";
|
||||
import { TextInput } from "../../components/form/inputs";
|
||||
import useFormSubmit from "../../lib/form/submit";
|
||||
import MutationButton from "../../components/form/mutation-button";
|
||||
import { useAliasAccountMutation, useMoveAccountMutation } from "../../lib/query/user";
|
||||
import { FormContext, useWithFormContext } from "../../lib/form/context";
|
||||
import { store } from "../../redux/store";
|
||||
|
||||
export default function UserMigration() {
|
||||
return (
|
||||
<FormWithData
|
||||
dataQuery={useVerifyCredentialsQuery}
|
||||
DataForm={UserMigrationForm}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function UserMigrationForm({ data: profile }) {
|
||||
return (
|
||||
<>
|
||||
<h2>Account Migration Settings</h2>
|
||||
<p>
|
||||
The following settings allow you to <strong>alias</strong> your account to
|
||||
another account elsewhere, or to <strong>move</strong> to another account.
|
||||
</p>
|
||||
<p>
|
||||
Account <strong>aliasing</strong> is harmless and reversible; you can
|
||||
set and unset up to five account aliases as many times as you wish.
|
||||
</p>
|
||||
<p>
|
||||
The account <strong>move</strong> action, on the other
|
||||
hand, has serious and irreversible consequences.
|
||||
</p>
|
||||
<p>
|
||||
For more information on account migration, please see <a href="https://docs.gotosocial.org/en/latest/user_guide/settings/#migration" target="_blank" className="docslink" rel="noreferrer">the documentation</a>.
|
||||
</p>
|
||||
<AliasForm data={profile} />
|
||||
<MoveForm data={profile} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AliasForm({ data: profile }) {
|
||||
const form = {
|
||||
alsoKnownAs: useArrayInput("also_known_as_uris", {
|
||||
source: profile,
|
||||
valueSelector: (p) => (
|
||||
p.source?.also_known_as_uris
|
||||
? p.source?.also_known_as_uris.map(entry => [entry])
|
||||
: []
|
||||
),
|
||||
length: 5,
|
||||
}),
|
||||
};
|
||||
|
||||
const [submitForm, result] = useFormSubmit(form, useAliasAccountMutation());
|
||||
|
||||
return (
|
||||
<form className="user-migration-alias" onSubmit={submitForm}>
|
||||
<div className="form-section-docs">
|
||||
<h3>Alias Account</h3>
|
||||
<a
|
||||
href="https://docs.gotosocial.org/en/latest/user_guide/settings/#alias-account"
|
||||
target="_blank"
|
||||
className="docslink"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more about account aliasing (opens in a new tab)
|
||||
</a>
|
||||
</div>
|
||||
<AlsoKnownAsURIs
|
||||
field={form.alsoKnownAs}
|
||||
/>
|
||||
<MutationButton
|
||||
disabled={false}
|
||||
label="Save account aliases"
|
||||
result={result}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function AlsoKnownAsURIs({ field: formField }) {
|
||||
return (
|
||||
<div className="aliases">
|
||||
<FormContext.Provider value={formField.ctx}>
|
||||
{formField.value.map((data, i) => (
|
||||
<AlsoKnownAsURI
|
||||
key={i}
|
||||
index={i}
|
||||
data={data}
|
||||
/>
|
||||
))}
|
||||
</FormContext.Provider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AlsoKnownAsURI({ index, data }) {
|
||||
const name = `${index}`;
|
||||
const form = useWithFormContext(index, {
|
||||
alsoKnownAsURI: useTextInput(
|
||||
name,
|
||||
// Only one field per entry.
|
||||
{ defaultValue: data[0] ?? "" },
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
label={`Alias #${index+1}`}
|
||||
field={form.alsoKnownAsURI}
|
||||
placeholder={`https://example.org/users/my_other_account_${index+1}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MoveForm({ data: profile }) {
|
||||
let urlStr = store.getState().oauth.instanceUrl ?? "";
|
||||
let url = new URL(urlStr);
|
||||
|
||||
const form = {
|
||||
movedToURI: useTextInput("moved_to_uri", {
|
||||
source: profile,
|
||||
valueSelector: (p) => p.moved?.url },
|
||||
),
|
||||
password: useTextInput("password"),
|
||||
};
|
||||
|
||||
const [submitForm, result] = useFormSubmit(form, useMoveAccountMutation(), {
|
||||
changedOnly: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<form className="user-migration-move" onSubmit={submitForm}>
|
||||
<div className="form-section-docs">
|
||||
<h3>Move Account</h3>
|
||||
<p>
|
||||
For a move to be successful, you must have already set an alias from the
|
||||
target account back to the account you're moving from (ie., this account),
|
||||
using the settings panel of the instance on which the target account resides.
|
||||
To do this, provide the following details to the other instance:
|
||||
</p>
|
||||
<dl className="migration-details">
|
||||
<div>
|
||||
<dt>Account handle/username:</dt>
|
||||
<dd>@{profile.acct}@{url.host}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Account URI:</dt>
|
||||
<dd>{urlStr}/users/{profile.username}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<br/>
|
||||
<a
|
||||
href="https://docs.gotosocial.org/en/latest/user_guide/settings/#move-account"
|
||||
target="_blank"
|
||||
className="docslink"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more about moving your account (opens in a new tab)
|
||||
</a>
|
||||
</div>
|
||||
<TextInput
|
||||
disabled={false}
|
||||
field={form.movedToURI}
|
||||
label="Move target URI"
|
||||
placeholder="https://example.org/users/my_new_account"
|
||||
/>
|
||||
<TextInput
|
||||
disabled={false}
|
||||
type="password"
|
||||
name="password"
|
||||
field={form.password}
|
||||
label="Current account password"
|
||||
/>
|
||||
<MutationButton
|
||||
disabled={false}
|
||||
label="Confirm account move"
|
||||
result={result}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
279
web/source/settings/views/user/profile.tsx
Normal file
279
web/source/settings/views/user/profile.tsx
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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/>.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
useTextInput,
|
||||
useFileInput,
|
||||
useBoolInput,
|
||||
useFieldArrayInput,
|
||||
useRadioInput
|
||||
} from "../../lib/form";
|
||||
|
||||
import useFormSubmit from "../../lib/form/submit";
|
||||
import { useWithFormContext, FormContext } from "../../lib/form/context";
|
||||
|
||||
import {
|
||||
TextInput,
|
||||
TextArea,
|
||||
FileInput,
|
||||
Checkbox,
|
||||
RadioGroup
|
||||
} from "../../components/form/inputs";
|
||||
|
||||
import FormWithData from "../../lib/form/form-with-data";
|
||||
import FakeProfile from "../../components/fake-profile";
|
||||
import MutationButton from "../../components/form/mutation-button";
|
||||
|
||||
import { useAccountThemesQuery, useInstanceV1Query } from "../../lib/query";
|
||||
import { useUpdateCredentialsMutation } from "../../lib/query/user";
|
||||
import { useVerifyCredentialsQuery } from "../../lib/query/oauth";
|
||||
|
||||
export default function UserProfile() {
|
||||
return (
|
||||
<FormWithData
|
||||
dataQuery={useVerifyCredentialsQuery}
|
||||
DataForm={UserProfileForm}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function UserProfileForm({ data: profile }) {
|
||||
/*
|
||||
User profile update form keys
|
||||
- bool bot
|
||||
- bool locked
|
||||
- string display_name
|
||||
- string note
|
||||
- file avatar
|
||||
- file header
|
||||
- bool enable_rss
|
||||
- bool hide_collections
|
||||
- string custom_css (if enabled)
|
||||
- string theme
|
||||
*/
|
||||
|
||||
const { data: instance } = useInstanceV1Query();
|
||||
const instanceConfig = React.useMemo(() => {
|
||||
return {
|
||||
allowCustomCSS: instance?.configuration?.accounts?.allow_custom_css === true,
|
||||
maxPinnedFields: instance?.configuration?.accounts?.max_profile_fields ?? 6
|
||||
};
|
||||
}, [instance]);
|
||||
|
||||
// Parse out available theme options into nice format.
|
||||
const { data: themes } = useAccountThemesQuery();
|
||||
let themeOptions = { "": "Default" };
|
||||
themes?.forEach((theme) => {
|
||||
let key = theme.file_name;
|
||||
let value = theme.title;
|
||||
if (theme.description) {
|
||||
value += " - " + theme.description;
|
||||
}
|
||||
themeOptions[key] = value;
|
||||
});
|
||||
|
||||
const form = {
|
||||
avatar: useFileInput("avatar", { withPreview: true }),
|
||||
header: useFileInput("header", { withPreview: true }),
|
||||
displayName: useTextInput("display_name", { source: profile }),
|
||||
note: useTextInput("note", { source: profile, valueSelector: (p) => p.source?.note }),
|
||||
bot: useBoolInput("bot", { source: profile }),
|
||||
locked: useBoolInput("locked", { source: profile }),
|
||||
discoverable: useBoolInput("discoverable", { source: profile}),
|
||||
enableRSS: useBoolInput("enable_rss", { source: profile }),
|
||||
hideCollections: useBoolInput("hide_collections", { source: profile }),
|
||||
fields: useFieldArrayInput("fields_attributes", {
|
||||
defaultValue: profile?.source?.fields,
|
||||
length: instanceConfig.maxPinnedFields
|
||||
}),
|
||||
customCSS: useTextInput("custom_css", { source: profile, nosubmit: !instanceConfig.allowCustomCSS }),
|
||||
theme: useRadioInput("theme", {
|
||||
source: profile,
|
||||
options: themeOptions,
|
||||
}),
|
||||
};
|
||||
|
||||
const [submitForm, result] = useFormSubmit(form, useUpdateCredentialsMutation(), {
|
||||
changedOnly: true,
|
||||
onFinish: () => {
|
||||
form.avatar.reset();
|
||||
form.header.reset();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<form className="user-profile" onSubmit={submitForm}>
|
||||
<h1>Profile</h1>
|
||||
<div className="overview">
|
||||
<FakeProfile
|
||||
avatar={form.avatar.previewValue ?? profile.avatar}
|
||||
header={form.header.previewValue ?? profile.header}
|
||||
display_name={form.displayName.value ?? profile.username}
|
||||
username={profile.username}
|
||||
role={profile.role}
|
||||
/>
|
||||
<div className="files">
|
||||
<div>
|
||||
<FileInput
|
||||
label="Header"
|
||||
field={form.header}
|
||||
accept="image/*"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FileInput
|
||||
label="Avatar"
|
||||
field={form.avatar}
|
||||
accept="image/*"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="theme">
|
||||
<div>
|
||||
<b id="theme-label">Theme</b>
|
||||
<br/>
|
||||
<span>After choosing theme and saving, <a href={profile.url} target="_blank">open your profile</a> and refresh to see changes.</span>
|
||||
</div>
|
||||
<RadioGroup
|
||||
aria-labelledby="theme-label"
|
||||
field={form.theme}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-section-docs">
|
||||
<h3>Basic Information</h3>
|
||||
<a
|
||||
href="https://docs.gotosocial.org/en/latest/user_guide/settings/#basic-information"
|
||||
target="_blank"
|
||||
className="docslink"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more about these settings (opens in a new tab)
|
||||
</a>
|
||||
</div>
|
||||
<TextInput
|
||||
field={form.displayName}
|
||||
label="Display name"
|
||||
placeholder="A GoToSocial user"
|
||||
/>
|
||||
<TextArea
|
||||
field={form.note}
|
||||
label="Bio"
|
||||
placeholder="Just trying out GoToSocial, my pronouns are they/them and I like sloths."
|
||||
rows={8}
|
||||
/>
|
||||
<b>Profile fields</b>
|
||||
<ProfileFields
|
||||
field={form.fields}
|
||||
/>
|
||||
|
||||
<div className="form-section-docs">
|
||||
<h3>Visibility and privacy</h3>
|
||||
<a
|
||||
href="https://docs.gotosocial.org/en/latest/user_guide/settings/#visibility-and-privacy"
|
||||
target="_blank"
|
||||
className="docslink"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more about these settings (opens in a new tab)
|
||||
</a>
|
||||
</div>
|
||||
<Checkbox
|
||||
field={form.locked}
|
||||
label="Manually approve follow requests"
|
||||
/>
|
||||
<Checkbox
|
||||
field={form.discoverable}
|
||||
label="Mark account as discoverable by search engines and directories"
|
||||
/>
|
||||
<Checkbox
|
||||
field={form.enableRSS}
|
||||
label="Enable RSS feed of Public posts"
|
||||
/>
|
||||
<Checkbox
|
||||
field={form.hideCollections}
|
||||
label="Hide who you follow / are followed by"
|
||||
/>
|
||||
|
||||
<div className="form-section-docs">
|
||||
<h3>Advanced</h3>
|
||||
<a
|
||||
href="https://docs.gotosocial.org/en/latest/user_guide/settings/#advanced"
|
||||
target="_blank"
|
||||
className="docslink"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more about these settings (opens in a new tab)
|
||||
</a>
|
||||
</div>
|
||||
<TextArea
|
||||
field={form.customCSS}
|
||||
label={`Custom CSS` + (!instanceConfig.allowCustomCSS ? ` (not enabled on this instance)` : ``)}
|
||||
className="monospace"
|
||||
rows={8}
|
||||
disabled={!instanceConfig.allowCustomCSS}
|
||||
/>
|
||||
<MutationButton
|
||||
disabled={false}
|
||||
label="Save profile info"
|
||||
result={result}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileFields({ field: formField }) {
|
||||
return (
|
||||
<div className="fields">
|
||||
<FormContext.Provider value={formField.ctx}>
|
||||
{formField.value.map((data, i) => (
|
||||
<Field
|
||||
key={i}
|
||||
index={i}
|
||||
data={data}
|
||||
/>
|
||||
))}
|
||||
</FormContext.Provider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ index, data }) {
|
||||
const form = useWithFormContext(index, {
|
||||
name: useTextInput("name", { defaultValue: data.name }),
|
||||
value: useTextInput("value", { defaultValue: data.value })
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="entry">
|
||||
<TextInput
|
||||
field={form.name}
|
||||
placeholder="Name"
|
||||
/>
|
||||
<TextInput
|
||||
field={form.value}
|
||||
placeholder="Value"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
web/source/settings/views/user/routes.tsx
Normal file
80
web/source/settings/views/user/routes.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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/>.
|
||||
*/
|
||||
|
||||
import { MenuItem } from "../../lib/navigation/menu";
|
||||
import React from "react";
|
||||
import { BaseUrlContext, useBaseUrl } from "../../lib/navigation/util";
|
||||
import UserProfile from "./profile";
|
||||
import UserSettings from "./settings";
|
||||
import UserMigration from "./migration";
|
||||
import { Redirect, Route, Router, Switch } from "wouter";
|
||||
|
||||
/**
|
||||
*
|
||||
* Basic user menu. Profile + accounts
|
||||
* settings, post settings, migration.
|
||||
*/
|
||||
export function UserMenu() {
|
||||
return (
|
||||
<MenuItem
|
||||
name="User"
|
||||
itemUrl="user"
|
||||
defaultChild="profile"
|
||||
>
|
||||
{/* Profile */}
|
||||
<MenuItem
|
||||
name="Profile"
|
||||
itemUrl="profile"
|
||||
icon="fa-user"
|
||||
/>
|
||||
{/* Settings */}
|
||||
<MenuItem
|
||||
name="Settings"
|
||||
itemUrl="settings"
|
||||
icon="fa-cogs"
|
||||
/>
|
||||
{/* Migration */}
|
||||
<MenuItem
|
||||
name="Migration"
|
||||
itemUrl="migration"
|
||||
icon="fa-exchange"
|
||||
/>
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserRouter() {
|
||||
const baseUrl = useBaseUrl();
|
||||
const thisBase = "/user";
|
||||
const absBase = baseUrl + thisBase;
|
||||
|
||||
return (
|
||||
<BaseUrlContext.Provider value={absBase}>
|
||||
<Router base={thisBase}>
|
||||
<Switch>
|
||||
<Route path="/profile" component={UserProfile} />
|
||||
<Route path="/settings" component={UserSettings} />
|
||||
<Route path="/migration" component={UserMigration} />
|
||||
{/* Fallback component */}
|
||||
<Route><Redirect to="/profile" /></Route>
|
||||
</Switch>
|
||||
</Router>
|
||||
</BaseUrlContext.Provider>
|
||||
);
|
||||
}
|
||||
169
web/source/settings/views/user/settings.tsx
Normal file
169
web/source/settings/views/user/settings.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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/>.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import query from "../../lib/query";
|
||||
import { useTextInput, useBoolInput } from "../../lib/form";
|
||||
import useFormSubmit from "../../lib/form/submit";
|
||||
import { Select, TextInput, Checkbox } from "../../components/form/inputs";
|
||||
import FormWithData from "../../lib/form/form-with-data";
|
||||
import Languages from "../../components/languages";
|
||||
import MutationButton from "../../components/form/mutation-button";
|
||||
|
||||
export default function UserSettings() {
|
||||
return (
|
||||
<FormWithData
|
||||
dataQuery={query.useVerifyCredentialsQuery}
|
||||
DataForm={UserSettingsForm}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function UserSettingsForm({ data }) {
|
||||
/* form keys
|
||||
- string source[privacy]
|
||||
- bool source[sensitive]
|
||||
- string source[language]
|
||||
- string source[status_content_type]
|
||||
*/
|
||||
|
||||
const form = {
|
||||
defaultPrivacy: useTextInput("source[privacy]", { source: data, defaultValue: "unlisted" }),
|
||||
isSensitive: useBoolInput("source[sensitive]", { source: data }),
|
||||
language: useTextInput("source[language]", { source: data, valueSelector: (s) => s.source.language?.toUpperCase() ?? "EN" }),
|
||||
statusContentType: useTextInput("source[status_content_type]", { source: data, defaultValue: "text/plain" }),
|
||||
};
|
||||
|
||||
const [submitForm, result] = useFormSubmit(form, query.useUpdateCredentialsMutation());
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Account Settings</h1>
|
||||
<form className="user-settings" onSubmit={submitForm}>
|
||||
<div className="form-section-docs">
|
||||
<h3>Post Settings</h3>
|
||||
<a
|
||||
href="https://docs.gotosocial.org/en/latest/user_guide/posts"
|
||||
target="_blank"
|
||||
className="docslink"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more about these settings (opens in a new tab)
|
||||
</a>
|
||||
</div>
|
||||
<Select field={form.language} label="Default post language" options={
|
||||
<Languages />
|
||||
}>
|
||||
</Select>
|
||||
<Select field={form.defaultPrivacy} label="Default post privacy" options={
|
||||
<>
|
||||
<option value="private">Private / followers-only</option>
|
||||
<option value="unlisted">Unlisted</option>
|
||||
<option value="public">Public</option>
|
||||
</>
|
||||
}>
|
||||
</Select>
|
||||
<Select field={form.statusContentType} label="Default post (and bio) format" options={
|
||||
<>
|
||||
<option value="text/plain">Plain (default)</option>
|
||||
<option value="text/markdown">Markdown</option>
|
||||
</>
|
||||
}>
|
||||
</Select>
|
||||
<Checkbox
|
||||
field={form.isSensitive}
|
||||
label="Mark my posts as sensitive by default"
|
||||
/>
|
||||
<MutationButton
|
||||
disabled={false}
|
||||
label="Save settings"
|
||||
result={result}
|
||||
/>
|
||||
</form>
|
||||
<PasswordChange />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordChange() {
|
||||
const form = {
|
||||
oldPassword: useTextInput("old_password"),
|
||||
newPassword: useTextInput("new_password", {
|
||||
validator(val) {
|
||||
if (val != "" && val == form.oldPassword.value) {
|
||||
return "New password same as old password";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
const verifyNewPassword = useTextInput("verifyNewPassword", {
|
||||
validator(val) {
|
||||
if (val != "" && val != form.newPassword.value) {
|
||||
return "Passwords do not match";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
});
|
||||
|
||||
const [submitForm, result] = useFormSubmit(form, query.usePasswordChangeMutation());
|
||||
|
||||
return (
|
||||
<form className="change-password" onSubmit={submitForm}>
|
||||
<div className="form-section-docs">
|
||||
<h3>Change Password</h3>
|
||||
<a
|
||||
href="https://docs.gotosocial.org/en/latest/user_guide/settings/#password-change"
|
||||
target="_blank"
|
||||
className="docslink"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more about this (opens in a new tab)
|
||||
</a>
|
||||
</div>
|
||||
<TextInput
|
||||
type="password"
|
||||
name="password"
|
||||
field={form.oldPassword}
|
||||
label="Current password"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<TextInput
|
||||
type="password"
|
||||
name="newPassword"
|
||||
field={form.newPassword}
|
||||
label="New password"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<TextInput
|
||||
type="password"
|
||||
name="confirmNewPassword"
|
||||
field={verifyNewPassword}
|
||||
label="Confirm new password"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<MutationButton
|
||||
disabled={false}
|
||||
label="Change password"
|
||||
result={result}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue