summaryrefslogtreecommitdiff
path: root/web/source/settings/views
diff options
context:
space:
mode:
authorLibravatar tobi <31960611+tsmethurst@users.noreply.github.com>2024-04-24 12:12:47 +0200
committerLibravatar GitHub <noreply@github.com>2024-04-24 11:12:47 +0100
commit7a1e6394831fb07e303c5ed0900dfe1ea4820de5 (patch)
treebcd526463b19a85fbe821dcad2276da401daec18 /web/source/settings/views
parent[chore]: Bump codeberg.org/gruf/go-mutexes from 1.4.0 to 1.4.1 (#2860) (diff)
downloadgotosocial-7a1e6394831fb07e303c5ed0900dfe1ea4820de5.tar.xz
[chore] Refactor settings panel routing (and other fixes) (#2864)
Diffstat (limited to 'web/source/settings/views')
-rw-r--r--web/source/settings/views/admin/actions/keys/expireremote.tsx60
-rw-r--r--web/source/settings/views/admin/actions/keys/index.tsx30
-rw-r--r--web/source/settings/views/admin/actions/media/cleanup.tsx59
-rw-r--r--web/source/settings/views/admin/actions/media/index.tsx30
-rw-r--r--web/source/settings/views/admin/emoji/category-select.tsx134
-rw-r--r--web/source/settings/views/admin/emoji/local/detail.tsx142
-rw-r--r--web/source/settings/views/admin/emoji/local/new-emoji.tsx112
-rw-r--r--web/source/settings/views/admin/emoji/local/overview.tsx173
-rw-r--r--web/source/settings/views/admin/emoji/local/use-shortcode.ts56
-rw-r--r--web/source/settings/views/admin/emoji/remote/index.tsx46
-rw-r--r--web/source/settings/views/admin/emoji/remote/steal-this-look.tsx235
-rw-r--r--web/source/settings/views/admin/routes.tsx177
-rw-r--r--web/source/settings/views/admin/settings/index.tsx190
-rw-r--r--web/source/settings/views/admin/settings/rules.tsx151
-rw-r--r--web/source/settings/views/moderation/accounts/detail/actions.tsx89
-rw-r--r--web/source/settings/views/moderation/accounts/detail/handlesignup.tsx118
-rw-r--r--web/source/settings/views/moderation/accounts/detail/index.tsx167
-rw-r--r--web/source/settings/views/moderation/accounts/index.tsx35
-rw-r--r--web/source/settings/views/moderation/accounts/pending/index.tsx40
-rw-r--r--web/source/settings/views/moderation/accounts/search/index.tsx131
-rw-r--r--web/source/settings/views/moderation/domain-permissions/detail.tsx262
-rw-r--r--web/source/settings/views/moderation/domain-permissions/export-format-table.tsx65
-rw-r--r--web/source/settings/views/moderation/domain-permissions/form.tsx153
-rw-r--r--web/source/settings/views/moderation/domain-permissions/import-export.tsx88
-rw-r--r--web/source/settings/views/moderation/domain-permissions/overview.tsx197
-rw-r--r--web/source/settings/views/moderation/domain-permissions/process.tsx400
-rw-r--r--web/source/settings/views/moderation/reports/detail.tsx243
-rw-r--r--web/source/settings/views/moderation/reports/overview.tsx99
-rw-r--r--web/source/settings/views/moderation/reports/username.tsx54
-rw-r--r--web/source/settings/views/moderation/routes.tsx201
-rw-r--r--web/source/settings/views/user/migration.tsx208
-rw-r--r--web/source/settings/views/user/profile.tsx279
-rw-r--r--web/source/settings/views/user/routes.tsx80
-rw-r--r--web/source/settings/views/user/settings.tsx169
34 files changed, 4673 insertions, 0 deletions
diff --git a/web/source/settings/views/admin/actions/keys/expireremote.tsx b/web/source/settings/views/admin/actions/keys/expireremote.tsx
new file mode 100644
index 000000000..c7a410267
--- /dev/null
+++ b/web/source/settings/views/admin/actions/keys/expireremote.tsx
@@ -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>
+ );
+}
diff --git a/web/source/settings/views/admin/actions/keys/index.tsx b/web/source/settings/views/admin/actions/keys/index.tsx
new file mode 100644
index 000000000..74bfd36ee
--- /dev/null
+++ b/web/source/settings/views/admin/actions/keys/index.tsx
@@ -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 />
+ </>
+ );
+}
diff --git a/web/source/settings/views/admin/actions/media/cleanup.tsx b/web/source/settings/views/admin/actions/media/cleanup.tsx
new file mode 100644
index 000000000..d4bae24a6
--- /dev/null
+++ b/web/source/settings/views/admin/actions/media/cleanup.tsx
@@ -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>
+ );
+}
diff --git a/web/source/settings/views/admin/actions/media/index.tsx b/web/source/settings/views/admin/actions/media/index.tsx
new file mode 100644
index 000000000..b3b805986
--- /dev/null
+++ b/web/source/settings/views/admin/actions/media/index.tsx
@@ -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 />
+ </>
+ );
+}
diff --git a/web/source/settings/views/admin/emoji/category-select.tsx b/web/source/settings/views/admin/emoji/category-select.tsx
new file mode 100644
index 000000000..683e146d8
--- /dev/null
+++ b/web/source/settings/views/admin/emoji/category-select.tsx
@@ -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>
+ );
+ }
+}
diff --git a/web/source/settings/views/admin/emoji/local/detail.tsx b/web/source/settings/views/admin/emoji/local/detail.tsx
new file mode 100644
index 000000000..2913b6c17
--- /dev/null
+++ b/web/source/settings/views/admin/emoji/local/detail.tsx
@@ -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&apos;t it cool?
+ </FakeToot>
+
+ {result.error && <Error error={result.error} />}
+ {deleteResult.error && <Error error={deleteResult.error} />}
+ </div>
+ </form>
+ </>
+ );
+} \ No newline at end of file
diff --git a/web/source/settings/views/admin/emoji/local/new-emoji.tsx b/web/source/settings/views/admin/emoji/local/new-emoji.tsx
new file mode 100644
index 000000000..73e846f16
--- /dev/null
+++ b/web/source/settings/views/admin/emoji/local/new-emoji.tsx
@@ -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&apos;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>
+ );
+}
diff --git a/web/source/settings/views/admin/emoji/local/overview.tsx b/web/source/settings/views/admin/emoji/local/overview.tsx
new file mode 100644
index 000000000..b28af59f3
--- /dev/null
+++ b/web/source/settings/views/admin/emoji/local/overview.tsx
@@ -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"
+ />
+ );
+}
diff --git a/web/source/settings/views/admin/emoji/local/use-shortcode.ts b/web/source/settings/views/admin/emoji/local/use-shortcode.ts
new file mode 100644
index 000000000..358e711b0
--- /dev/null
+++ b/web/source/settings/views/admin/emoji/local/use-shortcode.ts
@@ -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 "";
+ }
+ });
+}
diff --git a/web/source/settings/views/admin/emoji/remote/index.tsx b/web/source/settings/views/admin/emoji/remote/index.tsx
new file mode 100644
index 000000000..5521d1115
--- /dev/null
+++ b/web/source/settings/views/admin/emoji/remote/index.tsx
@@ -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} />}
+ </>
+ );
+}
diff --git a/web/source/settings/views/admin/emoji/remote/steal-this-look.tsx b/web/source/settings/views/admin/emoji/remote/steal-this-look.tsx
new file mode 100644
index 000000000..43d0b83e1
--- /dev/null
+++ b/web/source/settings/views/admin/emoji/remote/steal-this-look.tsx
@@ -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 });
+ }}
+ />
+ </>
+ );
+}
diff --git a/web/source/settings/views/admin/routes.tsx b/web/source/settings/views/admin/routes.tsx
new file mode 100644
index 000000000..29889046c
--- /dev/null
+++ b/web/source/settings/views/admin/routes.tsx
@@ -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>
+ );
+}
diff --git a/web/source/settings/views/admin/settings/index.tsx b/web/source/settings/views/admin/settings/index.tsx
new file mode 100644
index 000000000..abb34cf66
--- /dev/null
+++ b/web/source/settings/views/admin/settings/index.tsx
@@ -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>
+ );
+} \ No newline at end of file
diff --git a/web/source/settings/views/admin/settings/rules.tsx b/web/source/settings/views/admin/settings/rules.tsx
new file mode 100644
index 000000000..2b8a51c22
--- /dev/null
+++ b/web/source/settings/views/admin/settings/rules.tsx
@@ -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>
+ );
+}
diff --git a/web/source/settings/views/moderation/accounts/detail/actions.tsx b/web/source/settings/views/moderation/accounts/detail/actions.tsx
new file mode 100644
index 000000000..74c5371f1
--- /dev/null
+++ b/web/source/settings/views/moderation/accounts/detail/actions.tsx
@@ -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>
+ );
+}
diff --git a/web/source/settings/views/moderation/accounts/detail/handlesignup.tsx b/web/source/settings/views/moderation/accounts/detail/handlesignup.tsx
new file mode 100644
index 000000000..5655421ea
--- /dev/null
+++ b/web/source/settings/views/moderation/accounts/detail/handlesignup.tsx
@@ -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>
+ );
+}
diff --git a/web/source/settings/views/moderation/accounts/detail/index.tsx b/web/source/settings/views/moderation/accounts/detail/index.tsx
new file mode 100644
index 000000000..f507391d3
--- /dev/null
+++ b/web/source/settings/views/moderation/accounts/detail/index.tsx
@@ -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} />
+ }
+ </>
+ );
+}
diff --git a/web/source/settings/views/moderation/accounts/index.tsx b/web/source/settings/views/moderation/accounts/index.tsx
new file mode 100644
index 000000000..79ba2c674
--- /dev/null
+++ b/web/source/settings/views/moderation/accounts/index.tsx
@@ -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>
+ );
+}
diff --git a/web/source/settings/views/moderation/accounts/pending/index.tsx b/web/source/settings/views/moderation/accounts/pending/index.tsx
new file mode 100644
index 000000000..96b7796e5
--- /dev/null
+++ b/web/source/settings/views/moderation/accounts/pending/index.tsx
@@ -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>
+ );
+}
diff --git a/web/source/settings/views/moderation/accounts/search/index.tsx b/web/source/settings/views/moderation/accounts/search/index.tsx
new file mode 100644
index 000000000..7d5515a43
--- /dev/null
+++ b/web/source/settings/views/moderation/accounts/search/index.tsx
@@ -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"
+ />
+ </>
+ );
+}
diff --git a/web/source/settings/views/moderation/domain-permissions/detail.tsx b/web/source/settings/views/moderation/domain-permissions/detail.tsx
new file mode 100644
index 000000000..b9d439aee
--- /dev/null
+++ b/web/source/settings/views/moderation/domain-permissions/detail.tsx
@@ -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>
+ );
+}
diff --git a/web/source/settings/views/moderation/domain-permissions/export-format-table.tsx b/web/source/settings/views/moderation/domain-permissions/export-format-table.tsx
new file mode 100644
index 000000000..8971fdca8
--- /dev/null
+++ b/web/source/settings/views/moderation/domain-permissions/export-format-table.tsx
@@ -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>
+ </>
+ );
+} \ No newline at end of file
diff --git a/web/source/settings/views/moderation/domain-permissions/form.tsx b/web/source/settings/views/moderation/domain-permissions/form.tsx
new file mode 100644
index 000000000..ba0808873
--- /dev/null
+++ b/web/source/settings/views/moderation/domain-permissions/form.tsx
@@ -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>
+ </>
+ );
+}
diff --git a/web/source/settings/views/moderation/domain-permissions/import-export.tsx b/web/source/settings/views/moderation/domain-permissions/import-export.tsx
new file mode 100644
index 000000000..89f385107
--- /dev/null
+++ b/web/source/settings/views/moderation/domain-permissions/import-export.tsx
@@ -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("");
+ }}
+ >
+ &lt; back
+ </span>
+ &nbsp; 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>
+ );
+}
diff --git a/web/source/settings/views/moderation/domain-permissions/overview.tsx b/web/source/settings/views/moderation/domain-permissions/overview.tsx
new file mode 100644
index 000000000..d2bb77087
--- /dev/null
+++ b/web/source/settings/views/moderation/domain-permissions/overview.tsx
@@ -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}&nbsp;{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>
+ );
+}
diff --git a/web/source/settings/views/moderation/domain-permissions/process.tsx b/web/source/settings/views/moderation/domain-permissions/process.tsx
new file mode 100644
index 000000000..6c7cb218e
--- /dev/null
+++ b/web/source/settings/views/moderation/domain-permissions/process.tsx
@@ -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>
+ </>
+ );
+}
diff --git a/web/source/settings/views/moderation/reports/detail.tsx b/web/source/settings/views/moderation/reports/detail.tsx
new file mode 100644
index 000000000..9bb2de6b2
--- /dev/null
+++ b/web/source/settings/views/moderation/reports/detail.tsx
@@ -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>
+ );
+}
diff --git a/web/source/settings/views/moderation/reports/overview.tsx b/web/source/settings/views/moderation/reports/overview.tsx
new file mode 100644
index 000000000..ca8fc185c
--- /dev/null
+++ b/web/source/settings/views/moderation/reports/overview.tsx
@@ -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>
+ );
+}
diff --git a/web/source/settings/views/moderation/reports/username.tsx b/web/source/settings/views/moderation/reports/username.tsx
new file mode 100644
index 000000000..6fba0b804
--- /dev/null
+++ b/web/source/settings/views/moderation/reports/username.tsx
@@ -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>
+ );
+}
diff --git a/web/source/settings/views/moderation/routes.tsx b/web/source/settings/views/moderation/routes.tsx
new file mode 100644
index 000000000..238abaff6
--- /dev/null
+++ b/web/source/settings/views/moderation/routes.tsx
@@ -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>
+ );
+}
diff --git a/web/source/settings/views/user/migration.tsx b/web/source/settings/views/user/migration.tsx
new file mode 100644
index 000000000..69aae6059
--- /dev/null
+++ b/web/source/settings/views/user/migration.tsx
@@ -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>
+ );
+}
diff --git a/web/source/settings/views/user/profile.tsx b/web/source/settings/views/user/profile.tsx
new file mode 100644
index 000000000..08cd74bda
--- /dev/null
+++ b/web/source/settings/views/user/profile.tsx
@@ -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>
+ );
+}
diff --git a/web/source/settings/views/user/routes.tsx b/web/source/settings/views/user/routes.tsx
new file mode 100644
index 000000000..76ac50bc2
--- /dev/null
+++ b/web/source/settings/views/user/routes.tsx
@@ -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>
+ );
+}
diff --git a/web/source/settings/views/user/settings.tsx b/web/source/settings/views/user/settings.tsx
new file mode 100644
index 000000000..2827cc53f
--- /dev/null
+++ b/web/source/settings/views/user/settings.tsx
@@ -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>
+ );
+}