+ );
} else {
return ;
}
diff --git a/web/source/settings/admin/emoji/index.js b/web/source/settings/admin/emoji/local/index.js
similarity index 96%
rename from web/source/settings/admin/emoji/index.js
rename to web/source/settings/admin/emoji/local/index.js
index 0fcda8264..1ccdece72 100644
--- a/web/source/settings/admin/emoji/index.js
+++ b/web/source/settings/admin/emoji/local/index.js
@@ -24,7 +24,7 @@ const {Switch, Route} = require("wouter");
const EmojiOverview = require("./overview");
const EmojiDetail = require("./detail");
-const base = "/settings/admin/custom-emoji";
+const base = "/settings/custom-emoji/local";
module.exports = function CustomEmoji() {
return (
diff --git a/web/source/settings/admin/emoji/new-emoji.js b/web/source/settings/admin/emoji/local/new-emoji.js
similarity index 78%
rename from web/source/settings/admin/emoji/new-emoji.js
rename to web/source/settings/admin/emoji/local/new-emoji.js
index 8cd604c02..985be2d32 100644
--- a/web/source/settings/admin/emoji/new-emoji.js
+++ b/web/source/settings/admin/emoji/local/new-emoji.js
@@ -21,17 +21,19 @@
const Promise = require('bluebird');
const React = require("react");
-const FakeToot = require("../../components/fake-toot");
-const MutateButton = require("../../components/mutation-button");
+const FakeToot = require("../../../components/fake-toot");
+const MutateButton = require("../../../components/mutation-button");
const {
useTextInput,
useFileInput,
useComboBoxInput
-} = require("../../components/form");
+} = require("../../../components/form");
-const query = require("../../lib/query");
-const { CategorySelect } = require('./category-select');
+const query = require("../../../lib/query");
+const { CategorySelect } = require('../category-select');
+
+const shortcodeRegex = /^[a-z0-9_]+$/;
module.exports = function NewEmojiForm({ emoji }) {
const emojiCodes = React.useMemo(() => {
@@ -47,9 +49,26 @@ module.exports = function NewEmojiForm({ emoji }) {
const [onShortcodeChange, resetShortcode, { shortcode, setShortcode, shortcodeRef }] = useTextInput("shortcode", {
validator: function validateShortcode(code) {
- return emojiCodes.has(code)
- ? "Shortcode already in use"
- : "";
+ // technically invalid, but hacky fix to prevent validation error on page load
+ if (shortcode == "") {return "";}
+
+ if (emojiCodes.has(code)) {
+ return "Shortcode already in use";
+ }
+
+ if (code.length < 2 || code.length > 30) {
+ return "Shortcode must be between 2 and 30 characters";
+ }
+
+ if (code.toLowerCase() != code) {
+ return "Shortcode must be lowercase";
+ }
+
+ if (!shortcodeRegex.test(code)) {
+ return "Shortcode must only contain lowercase letters, numbers, and underscores";
+ }
+
+ return "";
}
});
@@ -78,11 +97,13 @@ module.exports = function NewEmojiForm({ emoji }) {
image,
shortcode,
category
- });
+ }).unwrap();
}).then(() => {
resetFile();
resetShortcode();
resetCategory();
+ }).catch((e) => {
+ console.error("Emoji upload error:", e);
});
}
diff --git a/web/source/settings/admin/emoji/overview.js b/web/source/settings/admin/emoji/local/overview.js
similarity index 88%
rename from web/source/settings/admin/emoji/overview.js
rename to web/source/settings/admin/emoji/local/overview.js
index b8ac87a0f..7a5cfaad6 100644
--- a/web/source/settings/admin/emoji/overview.js
+++ b/web/source/settings/admin/emoji/local/overview.js
@@ -23,10 +23,11 @@ const {Link} = require("wouter");
const NewEmojiForm = require("./new-emoji");
-const query = require("../../lib/query");
-const { useEmojiByCategory } = require("./category-select");
+const query = require("../../../lib/query");
+const { useEmojiByCategory } = require("../category-select");
+const Loading = require("../../../components/loading");
-const base = "/settings/admin/custom-emoji";
+const base = "/settings/custom-emoji/local";
module.exports = function EmojiOverview() {
const {
@@ -37,12 +38,12 @@ module.exports = function EmojiOverview() {
return (
<>
-
Custom Emoji
+
Custom Emoji (local)
{error &&
{error}
}
{isLoading
- ? "Loading..."
+ ?
: <>
diff --git a/web/source/settings/admin/emoji/remote/index.js b/web/source/settings/admin/emoji/remote/index.js
new file mode 100644
index 000000000..ae59673a5
--- /dev/null
+++ b/web/source/settings/admin/emoji/remote/index.js
@@ -0,0 +1,54 @@
+/*
+ GoToSocial
+ Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+"use strict";
+
+const React = require("react");
+
+const ParseFromToot = require("./parse-from-toot");
+
+const query = require("../../../lib/query");
+const Loading = require("../../../components/loading");
+
+module.exports = function RemoteEmoji() {
+ // local emoji are queried for shortcode collision detection
+ const {
+ data: emoji = [],
+ isLoading,
+ error
+ } = query.useGetAllEmojiQuery({filter: "domain:local"});
+
+ const emojiCodes = React.useMemo(() => {
+ return new Set(emoji.map((e) => e.shortcode));
+ }, [emoji]);
+
+ return (
+ <>
+
Custom Emoji (remote)
+ {error &&
+
{error}
+ }
+ {isLoading
+ ?
+ : <>
+
+ >
+ }
+ >
+ );
+};
\ No newline at end of file
diff --git a/web/source/settings/admin/emoji/remote/parse-from-toot.js b/web/source/settings/admin/emoji/remote/parse-from-toot.js
new file mode 100644
index 000000000..75ff8bf7a
--- /dev/null
+++ b/web/source/settings/admin/emoji/remote/parse-from-toot.js
@@ -0,0 +1,319 @@
+/*
+ GoToSocial
+ Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+"use strict";
+
+const Promise = require("bluebird");
+const React = require("react");
+const Redux = require("react-redux");
+const syncpipe = require("syncpipe");
+
+const {
+ useTextInput,
+ useComboBoxInput
+} = require("../../../components/form");
+
+const { CategorySelect } = require('../category-select');
+
+const query = require("../../../lib/query");
+const Loading = require("../../../components/loading");
+
+module.exports = function ParseFromToot({ emojiCodes }) {
+ const [searchStatus, { data, isLoading, isSuccess, error }] = query.useSearchStatusForEmojiMutation();
+ const instanceDomain = Redux.useSelector((state) => (new URL(state.oauth.instance).host));
+
+ const [onURLChange, _resetURL, { url }] = useTextInput("url");
+
+ const searchResult = React.useMemo(() => {
+ if (!isSuccess) {
+ return null;
+ }
+
+ if (data.type == "none") {
+ return "No results found";
+ }
+
+ if (data.domain == instanceDomain) {
+ return This is a local user/toot, all referenced emoji are already on your instance;
+ }
+
+ if (data.list.length == 0) {
+ return This {data.type == "statuses" ? "toot" : "account"} doesn't use any custom emoji;
+ }
+
+ return (
+
+ );
+ }, [isSuccess, data, instanceDomain, emojiCodes]);
+
+ function submitSearch(e) {
+ e.preventDefault();
+ searchStatus(url);
+ }
+
+ return (
+
+
Steal this look
+
+ {searchResult}
+
+ );
+};
+
+function makeEmojiState(emojiList, checked) {
+ /* Return a new object, with a key for every emoji's shortcode,
+ And a value for it's checkbox `checked` state.
+ */
+ return syncpipe(emojiList, [
+ (_) => _.map((emoji) => [emoji.shortcode, {
+ checked,
+ valid: true
+ }]),
+ (_) => Object.fromEntries(_)
+ ]);
+}
+
+function updateEmojiState(emojiState, checked) {
+ /* Create a new object with all emoji entries' checked state updated */
+ return syncpipe(emojiState, [
+ (_) => Object.entries(emojiState),
+ (_) => _.map(([key, val]) => [key, {
+ ...val,
+ checked
+ }]),
+ (_) => Object.fromEntries(_)
+ ]);
+}
+
+function CopyEmojiForm({ localEmojiCodes, type, domain, emojiList }) {
+ const [patchRemoteEmojis, patchResult] = query.usePatchRemoteEmojisMutation();
+ const [err, setError] = React.useState();
+
+ const toggleAllRef = React.useRef(null);
+ const [toggleAllState, setToggleAllState] = React.useState(0);
+ const [emojiState, setEmojiState] = React.useState(makeEmojiState(emojiList, false));
+ const [someSelected, setSomeSelected] = React.useState(false);
+
+ const [categoryState, resetCategory, { category }] = useComboBoxInput("category");
+
+ React.useEffect(() => {
+ if (emojiList != undefined) {
+ setEmojiState(makeEmojiState(emojiList, false));
+ }
+ }, [emojiList]);
+
+ React.useEffect(() => {
+ /* Updates (un)check all checkbox, based on shortcode checkboxes
+ Can be 0 (not checked), 1 (checked) or 2 (indeterminate)
+ */
+ if (toggleAllRef.current == null) {
+ return;
+ }
+
+ let values = Object.values(emojiState);
+ /* one or more boxes are checked */
+ let some = values.some((v) => v.checked);
+
+ let all = false;
+ if (some) {
+ /* there's not at least one unchecked box */
+ all = !values.some((v) => v.checked == false);
+ }
+
+ setSomeSelected(some);
+
+ if (some && !all) {
+ setToggleAllState(2);
+ toggleAllRef.current.indeterminate = true;
+ } else {
+ setToggleAllState(all ? 1 : 0);
+ toggleAllRef.current.indeterminate = false;
+ }
+ }, [emojiState, toggleAllRef]);
+
+ function updateEmoji(shortcode, value) {
+ setEmojiState({
+ ...emojiState,
+ [shortcode]: {
+ ...emojiState[shortcode],
+ ...value
+ }
+ });
+ }
+
+ function toggleAll(e) {
+ let selectAll = e.target.checked;
+
+ if (toggleAllState == 2) { // indeterminate
+ selectAll = false;
+ }
+
+ setEmojiState(updateEmojiState(emojiState, selectAll));
+ setToggleAllState(selectAll);
+ }
+
+ function submit(action) {
+ Promise.try(() => {
+ setError(null);
+ const selectedShortcodes = syncpipe(emojiState, [
+ (_) => Object.entries(_),
+ (_) => _.filter(([_shortcode, entry]) => entry.checked),
+ (_) => _.map(([shortcode, entry]) => {
+ if (action == "copy" && !entry.valid) {
+ throw `One or more selected emoji have non-unique shortcodes (${shortcode}), unselect them or pick a different local shortcode`;
+ }
+ return {
+ shortcode,
+ localShortcode: entry.shortcode
+ };
+ })
+ ]);
+
+ return patchRemoteEmojis({
+ action,
+ domain,
+ list: selectedShortcodes,
+ category
+ }).unwrap();
+ }).then(() => {
+ setEmojiState(makeEmojiState(emojiList, false));
+ resetCategory();
+ }).catch((e) => {
+ if (Array.isArray(e)) {
+ setError(e.map(([shortcode, msg]) => (
+