summaryrefslogtreecommitdiff
path: root/web/source/settings/views/admin/emoji/local
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/admin/emoji/local
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/admin/emoji/local')
-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
4 files changed, 483 insertions, 0 deletions
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 "";
+ }
+ });
+}