[frogend] Settings refactor (#1318)

* yakshave new form field structure

* fully refactor user profile settings form

* use rtk query api for profile settings

* refactor user post settings

* refactor password change form

* refactor admin settings

* FormWithData structure for user forms

* admin actions refactor

* whitespace

* fix user settings data prop

* remove superfluous logging

* cleanup old code

* refactor federation/suspend (overview, detail)

* mostly abstracted (emoji) checkbox list

* refactor parse-from-toot

* refactor custom-emoji, progress on federation bulk

* loading icon styling to prevent big spinny

* refactor federation import-export interface

* cleanup old files

* [chore] Update/add license headers for 2023

* redux fixes

* text-field exports

* appease the linter

* refactor authentication with RTK Query

* fix login/logout state transition weirdness

* fixes/cleanup

* small linter-related fixes

* add eslint license header check, fix existing files

* remove old code, clarify comment

* clarify suspend on subdomains

* collapse if/else

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

View File

@@ -1,19 +1,19 @@
/*
GoToSocial
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
GoToSocial
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
"use strict";
@@ -36,13 +36,15 @@ function useEmojiByCategory(emoji) {
), [emoji]);
}
function CategorySelect({value, categoryState, setIsNew=() => {}, children}) {
function CategorySelect({ field, children }) {
const { value, setIsNew } = field;
const {
data: emoji = [],
isLoading,
isSuccess,
error
} = query.useGetAllEmojiQuery({filter: "domain:local"});
} = query.useGetAllEmojiQuery({ filter: "domain:local" });
const emojiByCategory = useEmojiByCategory(emoji);
@@ -52,7 +54,7 @@ function CategorySelect({value, categoryState, setIsNew=() => {}, children}) {
const categoryItems = React.useMemo(() => {
return syncpipe(emojiByCategory, [
(_) => Object.keys(_), // just emoji category names
(_) => matchSorter(_, value, {threshold: matchSorter.rankings.NO_MATCH}), // sorted by complex algorithm
(_) => matchSorter(_, value, { threshold: matchSorter.rankings.NO_MATCH }), // sorted by complex algorithm
(_) => _.map((categoryName) => [ // map to input value, and selectable element with icon
categoryName,
<>
@@ -67,24 +69,24 @@ function CategorySelect({value, categoryState, setIsNew=() => {}, children}) {
if (value != undefined && isSuccess && value.trim().length > 0) {
setIsNew(!categories.has(value.trim()));
}
}, [categories, value, setIsNew, isSuccess]);
}, [categories, value, isSuccess, setIsNew]);
if (error) { // fall back to plain text input, but this would almost certainly have caused a bigger error message elsewhere
return (
<>
<input type="text" placeholder="e.g., reactions" onChange={(e) => {categoryState.value = e.target.value;}}/>;
<input type="text" placeholder="e.g., reactions" onChange={(e) => { field.value = e.target.value; }} />;
</>
);
} else if (isLoading) {
return <input type="text" value="Loading categories..." disabled={true}/>;
return <input type="text" value="Loading categories..." disabled={true} />;
}
return (
<ComboBox
state={categoryState}
field={field}
items={categoryItems}
label="Category"
placeHolder="e.g., reactions"
placeholder="e.g., reactions"
children={children}
/>
);

View File

@@ -19,155 +19,128 @@
"use strict";
const React = require("react");
const { useRoute, Link, Redirect } = require("wouter");
const { CategorySelect } = require("../category-select");
const { useComboBoxInput, useFileInput } = require("../../../components/form");
const query = require("../../../lib/query");
const { useComboBoxInput, useFileInput, useValue } = require("../../../lib/form");
const { CategorySelect } = require("../category-select");
const useFormSubmit = require("../../../lib/form/submit");
const FakeToot = require("../../../components/fake-toot");
const FormWithData = require("../../../lib/form/form-with-data");
const Loading = require("../../../components/loading");
const { FileInput } = require("../../../components/form/inputs");
const MutationButton = require("../../../components/form/mutation-button");
const { Error } = require("../../../components/error");
const base = "/settings/custom-emoji/local";
module.exports = function EmojiDetailRoute() {
let [_match, params] = useRoute(`${base}/:emojiId`);
if (params?.emojiId == undefined) {
return <Redirect to={base}/>;
return <Redirect to={base} />;
} else {
return (
<div className="emoji-detail">
<Link to={base}><a>&lt; go back</a></Link>
<EmojiDetailData emojiId={params.emojiId}/>
<FormWithData dataQuery={query.useGetEmojiQuery} queryArg={params.emojiId} DataForm={EmojiDetailForm} />
</div>
);
}
};
function EmojiDetailData({emojiId}) {
const {currentData: emoji, isLoading, error} = query.useGetEmojiQuery(emojiId);
function EmojiDetailForm({ data: emoji }) {
const form = {
id: useValue("id", emoji.id),
category: useComboBoxInput("category", { defaultValue: emoji.category }),
image: useFileInput("image", {
withPreview: true,
maxSize: 50 * 1024 // TODO: get from instance api
})
};
if (error) {
return (
<div className="error accent">
{error.status}: {error.data.error}
</div>
);
} else if (isLoading) {
return (
<div>
<Loading/>
</div>
);
} else {
return <EmojiDetail emoji={emoji}/>;
}
}
function EmojiDetail({emoji}) {
const [modifyEmoji, modifyResult] = query.useEditEmojiMutation();
const [isNewCategory, setIsNewCategory] = React.useState(false);
const [categoryState, _resetCategory, { category }] = useComboBoxInput("category", {defaultValue: emoji.category});
const [onFileChange, _resetFile, { image, imageURL, imageInfo }] = useFileInput("image", {
withPreview: true,
maxSize: 50 * 1024
});
function modifyCategory() {
modifyEmoji({id: emoji.id, category: category.trim()});
}
function modifyImage() {
modifyEmoji({id: emoji.id, image: image});
}
const [modifyEmoji, result] = useFormSubmit(form, query.useEditEmojiMutation());
// Automatic submitting of category change
React.useEffect(() => {
if (category != emoji.category && !categoryState.open && !isNewCategory && category.trim().length > 0) {
console.log("updating to", category);
modifyEmoji({id: emoji.id, category: category.trim()});
if (
form.category.hasChanged() &&
!form.category.state.open &&
!form.category.isNew) {
modifyEmoji();
}
}, [isNewCategory, category, categoryState.open, emoji.category, emoji.id, modifyEmoji]);
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [form.category.hasChanged(), form.category.isNew, form.category.state.open]);
const [deleteEmoji, deleteResult] = query.useDeleteEmojiMutation();
if (deleteResult.isSuccess) {
return <Redirect to={base} />;
}
return (
<>
<div className="emoji-header">
<img src={emoji.url} alt={emoji.shortcode} title={emoji.shortcode}/>
<img src={emoji.url} alt={emoji.shortcode} title={emoji.shortcode} />
<div>
<h2>{emoji.shortcode}</h2>
<DeleteButton id={emoji.id}/>
<MutationButton
label="Delete"
type="button"
onClick={() => deleteEmoji(emoji.id)}
className="danger"
showError={false}
result={deleteResult}
/>
</div>
</div>
<div className="left-border">
<h2>Modify this emoji {modifyResult.isLoading && "(processing..)"}</h2>
{modifyResult.error && <div className="error">
{modifyResult.error.status}: {modifyResult.error.data.error}
</div>}
<form onSubmit={modifyEmoji} className="left-border">
<h2>Modify this emoji {result.isLoading && <Loading />}</h2>
<div className="update-category">
<CategorySelect
value={category}
categoryState={categoryState}
setIsNew={setIsNewCategory}
field={form.category}
>
<button style={{visibility: (isNewCategory ? "initial" : "hidden")}} onClick={modifyCategory}>
Create
</button>
<MutationButton
name="create-category"
label="Create"
result={result}
showError={false}
style={{ visibility: (form.category.isNew ? "initial" : "hidden") }}
/>
</CategorySelect>
</div>
<div className="update-image">
<b>Image</b>
<div className="form-field file">
<label className="file-input button" htmlFor="image">
Browse
</label>
{imageInfo}
<input
className="hidden"
type="file"
id="image"
name="Image"
accept="image/png,image/gif"
onChange={onFileChange}
/>
</div>
<FileInput
field={form.image}
label="Image"
accept="image/png,image/gif"
/>
<button onClick={modifyImage} disabled={image == undefined}>Replace image</button>
<MutationButton
name="image"
label="Replace image"
showError={false}
result={result}
/>
<FakeToot>
Look at this new custom emoji <img
className="emoji"
src={imageURL ?? emoji.url}
src={form.image.previewURL ?? emoji.url}
title={`:${emoji.shortcode}:`}
alt={emoji.shortcode}
/> isn&apos;t it cool?
</FakeToot>
{result.error && <Error error={result.error} />}
{deleteResult.error && <Error error={deleteResult.error} />}
</div>
</div>
</form>
</>
);
}
function DeleteButton({id}) {
// TODO: confirmation dialog?
const [deleteEmoji, deleteResult] = query.useDeleteEmojiMutation();
let text = "Delete";
if (deleteResult.isLoading) {
text = "Deleting...";
}
if (deleteResult.isSuccess) {
return <Redirect to={base}/>;
}
return (
<button className="danger" onClick={() => deleteEmoji(id)} disabled={deleteResult.isLoading}>{text}</button>
);
}

View File

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

View File

@@ -18,101 +18,61 @@
"use strict";
const Promise = require('bluebird');
const React = require("react");
const FakeToot = require("../../../components/fake-toot");
const MutateButton = require("../../../components/mutation-button");
const query = require("../../../lib/query");
const {
useTextInput,
useFileInput,
useComboBoxInput
} = require("../../../components/form");
} = require("../../../lib/form");
const useShortcode = require("./use-shortcode");
const useFormSubmit = require("../../../lib/form/submit");
const {
TextInput, FileInput
} = require("../../../components/form/inputs");
const query = require("../../../lib/query");
const { CategorySelect } = require('../category-select');
const FakeToot = require("../../../components/fake-toot");
const MutationButton = require("../../../components/form/mutation-button");
const shortcodeRegex = /^[a-z0-9_]+$/;
module.exports = function NewEmojiForm() {
const shortcode = useShortcode();
module.exports = function NewEmojiForm({ emoji }) {
const emojiCodes = React.useMemo(() => {
return new Set(emoji.map((e) => e.shortcode));
}, [emoji]);
const [addEmoji, result] = query.useAddEmojiMutation();
const [onFileChange, resetFile, { image, imageURL, imageInfo }] = useFileInput("image", {
const image = useFileInput("image", {
withPreview: true,
maxSize: 50 * 1024
maxSize: 50 * 1024 // TODO: get from instance api?
});
const [onShortcodeChange, resetShortcode, { shortcode, setShortcode, shortcodeRef }] = useTextInput("shortcode", {
validator: function validateShortcode(code) {
// technically invalid, but hacky fix to prevent validation error on page load
if (shortcode == "") {return "";}
const category = useComboBoxInput("category");
if (emojiCodes.has(code)) {
return "Shortcode already in use";
}
if (code.length < 2 || code.length > 30) {
return "Shortcode must be between 2 and 30 characters";
}
if (code.toLowerCase() != code) {
return "Shortcode must be lowercase";
}
if (!shortcodeRegex.test(code)) {
return "Shortcode must only contain lowercase letters, numbers, and underscores";
}
return "";
}
});
const [categoryState, resetCategory, { category }] = useComboBoxInput("category");
const [submitForm, result] = useFormSubmit({
shortcode, image, category
}, query.useAddEmojiMutation());
React.useEffect(() => {
if (shortcode.length == 0) {
if (image != undefined) {
let [name, _ext] = image.name.split(".");
setShortcode(name);
if (shortcode.value.length == 0) {
if (image.value != undefined) {
let [name, _ext] = image.value.name.split(".");
shortcode.setter(name);
}
}
// we explicitly don't want to add 'shortcode' as a dependency here
// because we only want this to update to the filename if the field is empty
// at the moment the file is selected, not some time after when the field is emptied
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [image]);
function uploadEmoji(e) {
if (e) {
e.preventDefault();
}
/* We explicitly don't want to have 'shortcode' as a dependency here
because we only want to change the shortcode to the filename if the field is empty
at the moment the file is selected, not some time after when the field is emptied
*/
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [image.value]);
Promise.try(() => {
return addEmoji({
image,
shortcode,
category
}).unwrap();
}).then(() => {
resetFile();
resetShortcode();
resetCategory();
}).catch((e) => {
console.error("Emoji upload error:", e);
});
}
let emojiOrShortcode = `:${shortcode.value}:`;
let emojiOrShortcode = `:${shortcode}:`;
if (imageURL != undefined) {
if (image.previewValue != undefined) {
emojiOrShortcode = <img
className="emoji"
src={imageURL}
src={image.previewValue}
title={`:${shortcode}:`}
alt={shortcode}
/>;
@@ -126,42 +86,22 @@ module.exports = function NewEmojiForm({ emoji }) {
Look at this new custom emoji {emojiOrShortcode} isn&apos;t it cool?
</FakeToot>
<form onSubmit={uploadEmoji} className="form-flex">
<div className="form-field file">
<label className="file-input button" htmlFor="image">
Browse
</label>
{imageInfo}
<input
className="hidden"
type="file"
id="image"
name="Image"
accept="image/png,image/gif"
onChange={onFileChange}
/>
</div>
<div className="form-field text">
<label htmlFor="shortcode">
Shortcode, must be unique among the instance's local emoji
</label>
<input
type="text"
id="shortcode"
name="Shortcode"
ref={shortcodeRef}
onChange={onShortcodeChange}
value={shortcode}
/>
</div>
<CategorySelect
value={category}
categoryState={categoryState}
<form onSubmit={submitForm} className="form-flex">
<FileInput
field={image}
accept="image/png,image/gif"
/>
<MutateButton text="Upload emoji" result={result} />
<TextInput
field={shortcode}
label="Shortcode, must be unique among the instance's local emoji"
/>
<CategorySelect
field={category}
/>
<MutationButton label="Upload emoji" result={result} />
</form>
</div>
);

View File

@@ -1,25 +1,25 @@
/*
GoToSocial
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
GoToSocial
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
"use strict";
const React = require("react");
const {Link} = require("wouter");
const { Link } = require("wouter");
const NewEmojiForm = require("./new-emoji");
@@ -27,33 +27,31 @@ const query = require("../../../lib/query");
const { useEmojiByCategory } = require("../category-select");
const Loading = require("../../../components/loading");
const base = "/settings/custom-emoji/local";
module.exports = function EmojiOverview() {
module.exports = function EmojiOverview({ baseUrl }) {
const {
data: emoji = [],
isLoading,
error
} = query.useGetAllEmojiQuery({filter: "domain:local"});
} = query.useGetAllEmojiQuery({ filter: "domain:local" });
return (
<>
<h1>Custom Emoji (local)</h1>
{error &&
{error &&
<div className="error accent">{error}</div>
}
{isLoading
? <Loading/>
? <Loading />
: <>
<EmojiList emoji={emoji}/>
<NewEmojiForm emoji={emoji}/>
<EmojiList emoji={emoji} baseUrl={baseUrl} />
<NewEmojiForm emoji={emoji} />
</>
}
</>
);
};
function EmojiList({emoji}) {
function EmojiList({ emoji, baseUrl }) {
const emojiByCategory = useEmojiByCategory(emoji);
return (
@@ -62,24 +60,23 @@ function EmojiList({emoji}) {
<div className="list emoji-list">
{emoji.length == 0 && "No local emoji yet, add one below"}
{Object.entries(emojiByCategory).map(([category, entries]) => {
return <EmojiCategory key={category} category={category} entries={entries}/>;
return <EmojiCategory key={category} category={category} entries={entries} baseUrl={baseUrl} />;
})}
</div>
</div>
);
}
function EmojiCategory({category, entries}) {
function EmojiCategory({ category, entries, baseUrl }) {
return (
<div className="entry">
<b>{category}</b>
<div className="emoji-group">
{entries.map((e) => {
return (
<Link key={e.id} to={`${base}/${e.id}`}>
{/* <Link key={e.static_url} to={`${base}`}> */}
<Link key={e.id} to={`${baseUrl}/${e.id}`}>
<a>
<img src={e.url} alt={e.shortcode} title={`:${e.shortcode}:`}/>
<img src={e.url} alt={e.shortcode} title={`:${e.shortcode}:`} />
</a>
</Link>
);

View File

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

View File

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

View File

@@ -18,57 +18,35 @@
"use strict";
const Promise = require("bluebird");
const React = require("react");
const Redux = require("react-redux");
const syncpipe = require("syncpipe");
const query = require("../../../lib/query");
const {
useTextInput,
useComboBoxInput
} = require("../../../components/form");
useComboBoxInput,
useCheckListInput
} = require("../../../lib/form");
const useFormSubmit = require("../../../lib/form/submit");
const CheckList = require("../../../components/check-list");
const { CategorySelect } = require('../category-select');
const query = require("../../../lib/query");
const Loading = require("../../../components/loading");
const { TextInput } = require("../../../components/form/inputs");
const MutationButton = require("../../../components/form/mutation-button");
const { Error } = require("../../../components/error");
module.exports = function ParseFromToot({ emojiCodes }) {
const [searchStatus, { data, isLoading, isSuccess, error }] = query.useSearchStatusForEmojiMutation();
const instanceDomain = Redux.useSelector((state) => (new URL(state.oauth.instance).host));
const [searchStatus, result] = query.useSearchStatusForEmojiMutation();
const [onURLChange, _resetURL, { url }] = useTextInput("url");
const searchResult = React.useMemo(() => {
if (!isSuccess) {
return null;
}
if (data.type == "none") {
return "No results found";
}
if (data.domain == instanceDomain) {
return <b>This is a local user/toot, all referenced emoji are already on your instance</b>;
}
if (data.list.length == 0) {
return <b>This {data.type == "statuses" ? "toot" : "account"} doesn't use any custom emoji</b>;
}
return (
<CopyEmojiForm
localEmojiCodes={emojiCodes}
type={data.type}
domain={data.domain}
emojiList={data.list}
/>
);
}, [isSuccess, data, instanceDomain, emojiCodes]);
function submitSearch(e) {
e.preventDefault();
searchStatus(url);
if (url.trim().length != 0) {
searchStatus(url);
}
}
return (
@@ -87,233 +65,137 @@ module.exports = function ParseFromToot({ emojiCodes }) {
onChange={onURLChange}
value={url}
/>
<button disabled={isLoading}>
<button disabled={result.isLoading}>
<i className={[
"fa",
(isLoading
"fa fa-fw",
(result.isLoading
? "fa-refresh fa-spin"
: "fa-search")
].join(" ")} aria-hidden="true" title="Search"/>
].join(" ")} aria-hidden="true" title="Search" />
<span className="sr-only">Search</span>
</button>
</div>
{isLoading && <Loading/>}
{error && <div className="error">{error.data.error}</div>}
</div>
</form>
{searchResult}
<SearchResult result={result} localEmojiCodes={emojiCodes} />
</div>
);
};
function makeEmojiState(emojiList, checked) {
/* Return a new object, with a key for every emoji's shortcode,
And a value for it's checkbox `checked` state.
*/
return syncpipe(emojiList, [
(_) => _.map((emoji) => [emoji.shortcode, {
checked,
valid: true
}]),
(_) => Object.fromEntries(_)
]);
function SearchResult({ result, localEmojiCodes }) {
const { error, data, isSuccess, isError } = result;
if (!(isSuccess || isError)) {
return null;
}
if (error == "NONE_FOUND") {
return "No results found";
} else if (error == "LOCAL_INSTANCE") {
return <b>This is a local user/toot, all referenced emoji are already on your instance</b>;
} else if (error != undefined) {
return <Error error={result.error} />;
}
if (data.list.length == 0) {
return <b>This {data.type == "statuses" ? "toot" : "account"} doesn't use any custom emoji</b>;
}
return (
<CopyEmojiForm
localEmojiCodes={localEmojiCodes}
type={data.type}
domain={data.domain}
emojiList={data.list}
/>
);
}
function updateEmojiState(emojiState, checked) {
/* Create a new object with all emoji entries' checked state updated */
return syncpipe(emojiState, [
(_) => Object.entries(emojiState),
(_) => _.map(([key, val]) => [key, {
...val,
checked
}]),
(_) => Object.fromEntries(_)
]);
}
function CopyEmojiForm({ localEmojiCodes, type, emojiList }) {
const form = {
selectedEmoji: useCheckListInput("selectedEmoji", {
entries: emojiList,
uniqueKey: "shortcode"
}),
category: useComboBoxInput("category")
};
function CopyEmojiForm({ localEmojiCodes, type, domain, emojiList }) {
const [patchRemoteEmojis, patchResult] = query.usePatchRemoteEmojisMutation();
const [err, setError] = React.useState();
const [formSubmit, result] = useFormSubmit(form, query.usePatchRemoteEmojisMutation(), { changedOnly: false });
const toggleAllRef = React.useRef(null);
const [toggleAllState, setToggleAllState] = React.useState(0);
const [emojiState, setEmojiState] = React.useState(makeEmojiState(emojiList, false));
const [someSelected, setSomeSelected] = React.useState(false);
const [categoryState, resetCategory, { category }] = useComboBoxInput("category");
React.useEffect(() => {
if (emojiList != undefined) {
setEmojiState(makeEmojiState(emojiList, false));
}
}, [emojiList]);
React.useEffect(() => {
/* Updates (un)check all checkbox, based on shortcode checkboxes
Can be 0 (not checked), 1 (checked) or 2 (indeterminate)
*/
if (toggleAllRef.current == null) {
return;
}
let values = Object.values(emojiState);
/* one or more boxes are checked */
let some = values.some((v) => v.checked);
let all = false;
if (some) {
/* there's not at least one unchecked box */
all = !values.some((v) => v.checked == false);
}
setSomeSelected(some);
if (some && !all) {
setToggleAllState(2);
toggleAllRef.current.indeterminate = true;
} else {
setToggleAllState(all ? 1 : 0);
toggleAllRef.current.indeterminate = false;
}
}, [emojiState, toggleAllRef]);
function updateEmoji(shortcode, value) {
setEmojiState({
...emojiState,
[shortcode]: {
...emojiState[shortcode],
...value
}
});
}
function toggleAll(e) {
let selectAll = e.target.checked;
if (toggleAllState == 2) { // indeterminate
selectAll = false;
}
setEmojiState(updateEmojiState(emojiState, selectAll));
setToggleAllState(selectAll);
}
function submit(action) {
Promise.try(() => {
setError(null);
const selectedShortcodes = syncpipe(emojiState, [
(_) => Object.entries(_),
(_) => _.filter(([_shortcode, entry]) => entry.checked),
(_) => _.map(([shortcode, entry]) => {
if (action == "copy" && !entry.valid) {
throw `One or more selected emoji have non-unique shortcodes (${shortcode}), unselect them or pick a different local shortcode`;
}
return {
shortcode,
localShortcode: entry.shortcode
};
})
]);
return patchRemoteEmojis({
action,
domain,
list: selectedShortcodes,
category
}).unwrap();
}).then(() => {
setEmojiState(makeEmojiState(emojiList, false));
resetCategory();
}).catch((e) => {
if (Array.isArray(e)) {
setError(e.map(([shortcode, msg]) => (
<div key={shortcode}>
{shortcode}: <span style={{ fontWeight: "initial" }}>{msg}</span>
</div>
)));
} else {
setError(e);
}
});
}
const buttonsInactive = form.selectedEmoji.someSelected
? {}
: {
disabled: true,
title: "No emoji selected, cannot perform any actions"
};
return (
<div className="parsed">
<span>This {type == "statuses" ? "toot" : "account"} uses the following custom emoji, select the ones you want to copy/disable:</span>
<div className="emoji-list">
<label className="header">
<input
ref={toggleAllRef}
type="checkbox"
onChange={toggleAll}
checked={toggleAllState === 1}
/> All
</label>
{emojiList.map((emoji) => (
<EmojiEntry
key={emoji.shortcode}
emoji={emoji}
localEmojiCodes={localEmojiCodes}
updateEmoji={(value) => updateEmoji(emoji.shortcode, value)}
checked={emojiState[emoji.shortcode].checked}
/>
))}
</div>
<form onSubmit={formSubmit}>
<CheckList
field={form.selectedEmoji}
Component={EmojiEntry}
localEmojiCodes={localEmojiCodes}
/>
<CategorySelect
value={category}
categoryState={categoryState}
/>
<CategorySelect
field={form.category}
/>
<div className="action-buttons row">
<button disabled={!someSelected} onClick={() => submit("copy")}>{patchResult.isLoading ? "Processing..." : "Copy to local emoji"}</button>
<button disabled={!someSelected} onClick={() => submit("disable")} className="danger">{patchResult.isLoading ? "Processing..." : "Disable"}</button>
</div>
{err && <div className="error">
{err}
</div>}
{patchResult.isSuccess && <div>
Action applied to {patchResult.data.length} emoji
</div>}
<div className="action-buttons row">
<MutationButton name="copy" label="Copy to local emoji" result={result} showError={false} {...buttonsInactive} />
<MutationButton name="disable" label="Disable" result={result} className="button danger" showError={false} {...buttonsInactive} />
</div>
{result.error && (
Array.isArray(result.error)
? <ErrorList errors={result.error} />
: <Error error={result.error} />
)}
</form>
</div>
);
}
function EmojiEntry({ emoji, localEmojiCodes, updateEmoji, checked }) {
const [onShortcodeChange, _resetShortcode, { shortcode, shortcodeRef, shortcodeValid }] = useTextInput("shortcode", {
function ErrorList({ errors }) {
return (
<div className="error">
One or multiple emoji failed to process:
{errors.map(([shortcode, err]) => (
<div key={shortcode}>
<b>{shortcode}:</b> {err}
</div>
))}
</div>
);
}
function EmojiEntry({ entry: emoji, localEmojiCodes, onChange }) {
const shortcodeField = useTextInput("shortcode", {
defaultValue: emoji.shortcode,
validator: function validateShortcode(code) {
return (checked && localEmojiCodes.has(code))
return (emoji.checked && localEmojiCodes.has(code))
? "Shortcode already in use"
: "";
}
});
React.useEffect(() => {
updateEmoji({ valid: shortcodeValid });
onChange({ valid: shortcodeField.valid });
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [shortcodeValid]);
}, [shortcodeField.valid]);
return (
<label key={emoji.shortcode} className="row">
<input
type="checkbox"
onChange={(e) => updateEmoji({ checked: e.target.checked })}
checked={checked}
/>
<>
<img className="emoji" src={emoji.url} title={emoji.shortcode} />
<input
type="text"
id="shortcode"
name="Shortcode"
ref={shortcodeRef}
<TextInput
field={shortcodeField}
onChange={(e) => {
onShortcodeChange(e);
updateEmoji({ shortcode: e.target.value, checked: true });
shortcodeField.onChange(e);
onChange({ shortcode: e.target.value, checked: true });
}}
value={shortcode}
/>
</label>
</>
);
}