mirror of
1
Fork 0

refactor custom-emoji, progress on federation bulk

This commit is contained in:
f0x 2023-01-12 23:33:39 +00:00
parent 59413c3482
commit 9b6a54032c
28 changed files with 969 additions and 430 deletions

View File

@ -0,0 +1,282 @@
/*
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 <http://www.gnu.org/licenses/>.
*/
"use strict";
const Promise = require("bluebird");
const React = require("react");
const Redux = require("react-redux");
const { Switch, Route, Link, Redirect, useRoute, useLocation } = require("wouter");
const fileDownload = require("js-file-download");
const { formFields } = require("../components/form-fields");
const api = require("../lib/api");
const adminActions = require("../redux/reducers/admin").actions;
const submit = require("../lib/submit");
const BackButton = require("../components/back-button");
const Loading = require("../components/loading");
const { matchSorter } = require("match-sorter");
const base = "/settings/admin/federation";
module.exports = function AdminSettings() {
const dispatch = Redux.useDispatch();
const loadedBlockedInstances = Redux.useSelector(state => state.admin.loadedBlockedInstances);
React.useEffect(() => {
if (!loadedBlockedInstances) {
Promise.try(() => {
return dispatch(api.admin.fetchDomainBlocks());
});
}
}, [dispatch, loadedBlockedInstances]);
if (!loadedBlockedInstances) {
return (
<div>
<h1>Federation</h1>
<div>
<Loading />
</div>
</div>
);
}
return (
<Switch>
<Route path={`${base}/:domain`}>
<InstancePageWrapped />
</Route>
<InstanceOverview />
</Switch>
);
};
function InstanceOverview() {
const [filter, setFilter] = React.useState("");
const blockedInstances = Redux.useSelector(state => state.admin.blockedInstances);
const [_location, setLocation] = useLocation();
const filteredInstances = React.useMemo(() => {
return matchSorter(Object.values(blockedInstances), filter, { keys: ["domain"] });
}, [blockedInstances, filter]);
function filterFormSubmit(e) {
e.preventDefault();
setLocation(`${base}/${filter}`);
}
return (
<>
<h1>Federation</h1>
Here you can see an overview of blocked instances.
<div className="instance-list">
<h2>Blocked instances</h2>
<form action={`${base}/view`} className="filter" role="search" onSubmit={filterFormSubmit}>
<input name="domain" value={filter} onChange={(e) => setFilter(e.target.value)} />
<Link to={`${base}/${filter}`}><a className="button">Add block</a></Link>
</form>
<div className="list scrolling">
{filteredInstances.map((entry) => {
return (
<Link key={entry.domain} to={`${base}/${entry.domain}`}>
<a className="entry nounderline">
<span id="domain">
{entry.domain}
</span>
<span id="date">
{new Date(entry.created_at).toLocaleString()}
</span>
</a>
</Link>
);
})}
</div>
</div>
<BulkBlocking />
</>
);
}
const Bulk = formFields(adminActions.updateBulkBlockVal, (state) => state.admin.bulkBlock);
function BulkBlocking() {
const dispatch = Redux.useDispatch();
const { bulkBlock, blockedInstances } = Redux.useSelector(state => state.admin);
const [errorMsg, setError] = React.useState("");
const [statusMsg, setStatus] = React.useState("");
function importBlocks() {
setStatus("Processing");
setError("");
return Promise.try(() => {
return dispatch(api.admin.bulkDomainBlock());
}).then(({ success, invalidDomains }) => {
return Promise.try(() => {
return resetBulk();
}).then(() => {
dispatch(adminActions.updateBulkBlockVal(["list", invalidDomains.join("\n")]));
let stat = "";
if (success == 0) {
return setError("No valid domains in import");
} else if (success == 1) {
stat = "Imported 1 domain";
} else {
stat = `Imported ${success} domains`;
}
if (invalidDomains.length > 0) {
if (invalidDomains.length == 1) {
stat += ", input contained 1 invalid domain.";
} else {
stat += `, input contained ${invalidDomains.length} invalid domains.`;
}
} else {
stat += "!";
}
setStatus(stat);
});
}).catch((e) => {
setError(e.message);
setStatus("");
});
}
function exportBlocks() {
return Promise.try(() => {
setStatus("Exporting");
setError("");
let asJSON = bulkBlock.exportType.startsWith("json");
let _asCSV = bulkBlock.exportType.startsWith("csv");
let exportList = Object.values(blockedInstances).map((entry) => {
if (asJSON) {
return {
domain: entry.domain,
public_comment: entry.public_comment
};
} else {
return entry.domain;
}
});
if (bulkBlock.exportType == "json") {
return dispatch(adminActions.updateBulkBlockVal(["list", JSON.stringify(exportList)]));
} else if (bulkBlock.exportType == "json-download") {
return fileDownload(JSON.stringify(exportList), "block-export.json");
} else if (bulkBlock.exportType == "plain") {
return dispatch(adminActions.updateBulkBlockVal(["list", exportList.join("\n")]));
}
}).then(() => {
setStatus("Exported!");
}).catch((e) => {
setError(e.message);
setStatus("");
});
}
function resetBulk(e) {
if (e != undefined) {
e.preventDefault();
}
return dispatch(adminActions.resetBulkBlockVal());
}
function disableInfoFields(props = {}) {
if (bulkBlock.list[0] == "[") {
return {
...props,
disabled: true,
placeHolder: "Domain list is a JSON import, input disabled"
};
} else {
return props;
}
}
return (
<div className="bulk">
<h2>Import / Export <a onClick={resetBulk}>reset</a></h2>
<Bulk.TextArea
id="list"
name="Domains, one per line"
placeHolder={`google.com\nfacebook.com`}
/>
<Bulk.TextArea
id="public_comment"
name="Public comment"
inputProps={disableInfoFields({ rows: 3 })}
/>
<Bulk.TextArea
id="private_comment"
name="Private comment"
inputProps={disableInfoFields({ rows: 3 })}
/>
<Bulk.Checkbox
id="obfuscate"
name="Obfuscate domains? "
inputProps={disableInfoFields()}
/>
<div className="hidden">
<Bulk.File
id="json"
fileType="application/json"
withPreview={false}
/>
</div>
<div className="messagebutton">
<div>
<button type="submit" onClick={importBlocks}>Import</button>
</div>
<div>
<button type="submit" onClick={exportBlocks}>Export</button>
<Bulk.Select id="exportType" name="Export type" options={
<>
<option value="plain">One per line in text field</option>
<option value="json">JSON in text field</option>
<option value="json-download">JSON file download</option>
<option disabled value="csv">CSV in text field (glitch-soc)</option>
<option disabled value="csv-download">CSV file download (glitch-soc)</option>
</>
} />
</div>
<br />
<div>
{errorMsg.length > 0 &&
<div className="error accent">{errorMsg}</div>
}
{statusMsg.length > 0 &&
<div className="accent">{statusMsg}</div>
}
</div>
</div>
</div>
);
}

View File

@ -36,13 +36,15 @@ function useEmojiByCategory(emoji) {
), [emoji]); ), [emoji]);
} }
function CategorySelect({value, categoryState, setIsNew=() => {}, children}) { function CategorySelect({ field, children }) {
const { value, setIsNew } = field;
const { const {
data: emoji = [], data: emoji = [],
isLoading, isLoading,
isSuccess, isSuccess,
error error
} = query.useGetAllEmojiQuery({filter: "domain:local"}); } = query.useGetAllEmojiQuery({ filter: "domain:local" });
const emojiByCategory = useEmojiByCategory(emoji); const emojiByCategory = useEmojiByCategory(emoji);
@ -52,7 +54,7 @@ function CategorySelect({value, categoryState, setIsNew=() => {}, children}) {
const categoryItems = React.useMemo(() => { const categoryItems = React.useMemo(() => {
return syncpipe(emojiByCategory, [ return syncpipe(emojiByCategory, [
(_) => Object.keys(_), // just emoji category names (_) => 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 (_) => _.map((categoryName) => [ // map to input value, and selectable element with icon
categoryName, categoryName,
<> <>
@ -67,24 +69,24 @@ function CategorySelect({value, categoryState, setIsNew=() => {}, children}) {
if (value != undefined && isSuccess && value.trim().length > 0) { if (value != undefined && isSuccess && value.trim().length > 0) {
setIsNew(!categories.has(value.trim())); 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 if (error) { // fall back to plain text input, but this would almost certainly have caused a bigger error message elsewhere
return ( 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) { } else if (isLoading) {
return <input type="text" value="Loading categories..." disabled={true}/>; return <input type="text" value="Loading categories..." disabled={true} />;
} }
return ( return (
<ComboBox <ComboBox
state={categoryState} field={field}
items={categoryItems} items={categoryItems}
label="Category" label="Category"
placeHolder="e.g., reactions" placeholder="e.g., reactions"
children={children} children={children}
/> />
); );

View File

@ -19,15 +19,21 @@
"use strict"; "use strict";
const React = require("react"); const React = require("react");
const { useRoute, Link, Redirect } = require("wouter"); const { useRoute, Link, Redirect } = require("wouter");
const { CategorySelect } = require("../category-select");
const { useComboBoxInput, useFileInput } = require("../../../lib/form");
const query = require("../../../lib/query"); 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 FakeToot = require("../../../components/fake-toot");
const FormWithData = require("../../../lib/form/form-with-data");
const Loading = require("../../../components/loading"); 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"; const base = "/settings/custom-emoji/local";
@ -39,57 +45,41 @@ module.exports = function EmojiDetailRoute() {
return ( return (
<div className="emoji-detail"> <div className="emoji-detail">
<Link to={base}><a>&lt; go back</a></Link> <Link to={base}><a>&lt; go back</a></Link>
<EmojiDetailData emojiId={params.emojiId} /> <FormWithData dataQuery={query.useGetEmojiQuery} arg={params.emojiId} DataForm={EmojiDetailForm} />
</div> </div>
); );
} }
}; };
function EmojiDetailData({ emojiId }) { function EmojiDetailForm({ data: emoji }) {
const { currentData: emoji, isLoading, error } = query.useGetEmojiQuery(emojiId); 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) { const [modifyEmoji, result] = useFormSubmit(form, query.useEditEmojiMutation());
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 });
}
// Automatic submitting of category change
React.useEffect(() => { React.useEffect(() => {
if (category != emoji.category && !categoryState.open && !isNewCategory && category?.trim().length > 0) { if (
modifyEmoji({ id: emoji.id, category: category.trim() }); form.category.hasChanged() &&
!form.category.state.open &&
!form.category.isNew) {
modifyEmoji();
} }
}, [isNewCategory, category, categoryState.open, emoji.category, emoji.id, modifyEmoji]); }, [form.category.hasChanged(), form.category.isNew, form.category.state.open]);
const [deleteEmoji, deleteResult] = query.useDeleteEmojiMutation();
if (deleteResult.isSuccess) {
return <Redirect to={base} />;
}
console.log(form.category);
return ( return (
<> <>
@ -97,58 +87,62 @@ function EmojiDetail({ emoji }) {
<img src={emoji.url} alt={emoji.shortcode} title={emoji.shortcode} /> <img src={emoji.url} alt={emoji.shortcode} title={emoji.shortcode} />
<div> <div>
<h2>{emoji.shortcode}</h2> <h2>{emoji.shortcode}</h2>
<DeleteButton id={emoji.id} /> <MutationButton
label="Delete"
type="button"
onClick={() => deleteEmoji(emoji.id)}
className="danger button-inline"
showError={false}
result={deleteResult}
/>
</div> </div>
</div> </div>
<div className="left-border"> <form onSubmit={modifyEmoji} className="left-border">
<h2>Modify this emoji {modifyResult.isLoading && "(processing..)"}</h2> <h2>Modify this emoji {result.isLoading && <Loading />}</h2>
{modifyResult.error && <div className="error">
{modifyResult.error.status}: {modifyResult.error.data.error}
</div>}
<div className="update-category"> <div className="update-category">
<CategorySelect <CategorySelect
value={category} field={form.category}
categoryState={categoryState}
setIsNew={setIsNewCategory}
> >
<button style={{ visibility: (isNewCategory ? "initial" : "hidden") }} onClick={modifyCategory}> <MutationButton
Create name="create-category"
</button> label="Create"
result={result}
showError={false}
style={{ visibility: (form.category.isNew ? "initial" : "hidden") }}
/>
</CategorySelect> </CategorySelect>
</div> </div>
<div className="update-image"> <div className="update-image">
<b>Image</b> <FileInput
<div className="form-field file"> field={form.image}
<label className="file-input button" htmlFor="image"> label="Image"
Browse accept="image/png,image/gif"
</label> />
{imageInfo}
<input
className="hidden"
type="file"
id="image"
name="Image"
accept="image/png,image/gif"
onChange={onFileChange}
/>
</div>
<button onClick={modifyImage} disabled={image == undefined}>Replace image</button> <MutationButton
name="image"
label="Replace image"
showError={false}
className="button-inline"
result={result}
/>
<FakeToot> <FakeToot>
Look at this new custom emoji <img Look at this new custom emoji <img
className="emoji" className="emoji"
src={imageURL ?? emoji.url} src={form.image.previewURL ?? emoji.url}
title={`:${emoji.shortcode}:`} title={`:${emoji.shortcode}:`}
alt={emoji.shortcode} alt={emoji.shortcode}
/> isn&apos;t it cool? /> isn&apos;t it cool?
</FakeToot> </FakeToot>
{result.error && <Error error={result.error} />}
{deleteResult.error && <Error error={deleteResult.error} />}
</div> </div>
</div> </form>
</> </>
); );
} }

View File

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

View File

@ -21,98 +21,65 @@
const Promise = require('bluebird'); const Promise = require('bluebird');
const React = require("react"); const React = require("react");
const FakeToot = require("../../../components/fake-toot"); const query = require("../../../lib/query");
const MutationButton = require("../../../components/form/mutation-button");
const { const {
useTextInput, useTextInput,
useFileInput, useFileInput,
useComboBoxInput useComboBoxInput
} = require("../../../lib/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 { 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 image = useFileInput("image", {
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", {
withPreview: true, withPreview: true,
maxSize: 50 * 1024 maxSize: 50 * 1024 // TODO: get from instance api?
}); });
const [onShortcodeChange, resetShortcode, { shortcode, setShortcode, shortcodeRef }] = useTextInput("shortcode", { const category = useComboBoxInput("category");
validator: function validateShortcode(code) {
// technically invalid, but hacky fix to prevent validation error on page load
if (shortcode == "") { return ""; }
if (emojiCodes.has(code)) { const [submitForm, result] = useFormSubmit({
return "Shortcode already in use"; shortcode, image, category
} }, query.useAddEmojiMutation());
if (code.length < 2 || code.length > 30) { // const [onFileChange, resetFile, { image, imageURL, imageInfo }] = useFileInput("image", {
return "Shortcode must be between 2 and 30 characters"; // withPreview: true,
} // maxSize: 50 * 1024
// });
if (code.toLowerCase() != code) { // const [categoryState, resetCategory, { category }] = useComboBoxInput("category");
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");
React.useEffect(() => { React.useEffect(() => {
if (shortcode.length == 0) { if (shortcode.value.length == 0) {
if (image != undefined) { if (image.value != undefined) {
let [name, _ext] = image.name.split("."); let [name, _ext] = image.value.name.split(".");
setShortcode(name); shortcode.setter(name);
} }
} }
// we explicitly don't want to add 'shortcode' as a dependency here // 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 // 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 // at the moment the file is selected, not some time after when the field is emptied
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [image]); }, [image.value]);
function uploadEmoji(e) { let emojiOrShortcode = `:${shortcode.value}:`;
if (e) {
e.preventDefault();
}
Promise.try(() => { if (image.previewValue != undefined) {
return addEmoji({
image,
shortcode,
category
});
}).then((res) => {
if (res.error == undefined) {
resetFile();
resetShortcode();
resetCategory();
}
});
}
let emojiOrShortcode = `:${shortcode}:`;
if (imageURL != undefined) {
emojiOrShortcode = <img emojiOrShortcode = <img
className="emoji" className="emoji"
src={imageURL} src={image.previewValue}
title={`:${shortcode}:`} title={`:${shortcode}:`}
alt={shortcode} alt={shortcode}
/>; />;
@ -126,39 +93,19 @@ module.exports = function NewEmojiForm({ emoji }) {
Look at this new custom emoji {emojiOrShortcode} isn&apos;t it cool? Look at this new custom emoji {emojiOrShortcode} isn&apos;t it cool?
</FakeToot> </FakeToot>
<form onSubmit={uploadEmoji} className="form-flex"> <form onSubmit={submitForm} className="form-flex">
<div className="form-field file"> <FileInput
<label className="file-input button" htmlFor="image"> field={image}
Browse accept="image/png,image/gif"
</label> />
{imageInfo}
<input
className="hidden"
type="file"
id="image"
name="Image"
accept="image/png,image/gif"
onChange={onFileChange}
/>
</div>
<div className="form-field text"> <TextInput
<label htmlFor="shortcode"> field={shortcode}
Shortcode, must be unique among the instance's local emoji label="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 <CategorySelect
value={category} field={category}
categoryState={categoryState}
/> />
<MutationButton label="Upload emoji" result={result} /> <MutationButton label="Upload emoji" result={result} />

View File

@ -19,7 +19,7 @@
"use strict"; "use strict";
const React = require("react"); const React = require("react");
const {Link} = require("wouter"); const { Link } = require("wouter");
const NewEmojiForm = require("./new-emoji"); const NewEmojiForm = require("./new-emoji");
@ -27,33 +27,31 @@ const query = require("../../../lib/query");
const { useEmojiByCategory } = require("../category-select"); const { useEmojiByCategory } = require("../category-select");
const Loading = require("../../../components/loading"); const Loading = require("../../../components/loading");
const base = "/settings/custom-emoji/local"; module.exports = function EmojiOverview({ baseUrl }) {
module.exports = function EmojiOverview() {
const { const {
data: emoji = [], data: emoji = [],
isLoading, isLoading,
error error
} = query.useGetAllEmojiQuery({filter: "domain:local"}); } = query.useGetAllEmojiQuery({ filter: "domain:local" });
return ( return (
<> <>
<h1>Custom Emoji (local)</h1> <h1>Custom Emoji (local)</h1>
{error && {error &&
<div className="error accent">{error}</div> <div className="error accent">{error}</div>
} }
{isLoading {isLoading
? <Loading/> ? <Loading />
: <> : <>
<EmojiList emoji={emoji}/> <EmojiList emoji={emoji} baseUrl={baseUrl} />
<NewEmojiForm emoji={emoji}/> <NewEmojiForm emoji={emoji} />
</> </>
} }
</> </>
); );
}; };
function EmojiList({emoji}) { function EmojiList({ emoji, baseUrl }) {
const emojiByCategory = useEmojiByCategory(emoji); const emojiByCategory = useEmojiByCategory(emoji);
return ( return (
@ -62,24 +60,23 @@ function EmojiList({emoji}) {
<div className="list emoji-list"> <div className="list emoji-list">
{emoji.length == 0 && "No local emoji yet, add one below"} {emoji.length == 0 && "No local emoji yet, add one below"}
{Object.entries(emojiByCategory).map(([category, entries]) => { {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>
</div> </div>
); );
} }
function EmojiCategory({category, entries}) { function EmojiCategory({ category, entries, baseUrl }) {
return ( return (
<div className="entry"> <div className="entry">
<b>{category}</b> <b>{category}</b>
<div className="emoji-group"> <div className="emoji-group">
{entries.map((e) => { {entries.map((e) => {
return ( return (
<Link key={e.id} to={`${base}/${e.id}`}> <Link key={e.id} to={`${baseUrl}/${e.id}`}>
{/* <Link key={e.static_url} to={`${base}`}> */}
<a> <a>
<img src={e.url} alt={e.shortcode} title={`:${e.shortcode}:`}/> <img src={e.url} alt={e.shortcode} title={`:${e.shortcode}:`} />
</a> </a>
</Link> </Link>
); );

View File

@ -0,0 +1,61 @@
/*
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 <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

@ -18,10 +18,9 @@
"use strict"; "use strict";
const Promise = require("bluebird");
const React = require("react"); const React = require("react");
const Redux = require("react-redux");
const syncpipe = require("syncpipe"); const query = require("../../../lib/query");
const { const {
useTextInput, useTextInput,
@ -34,44 +33,15 @@ const useFormSubmit = require("../../../lib/form/submit");
const CheckList = require("../../../components/check-list"); const CheckList = require("../../../components/check-list");
const { CategorySelect } = require('../category-select'); const { CategorySelect } = require('../category-select');
const query = require("../../../lib/query");
const Loading = require("../../../components/loading");
const { TextInput } = require("../../../components/form/inputs"); const { TextInput } = require("../../../components/form/inputs");
const MutationButton = require("../../../components/form/mutation-button"); const MutationButton = require("../../../components/form/mutation-button");
const { Error } = require("../../../components/error");
module.exports = function ParseFromToot({ emojiCodes }) { module.exports = function ParseFromToot({ emojiCodes }) {
const [searchStatus, { data, isLoading, isSuccess, error }] = query.useSearchStatusForEmojiMutation(); const [searchStatus, result] = query.useSearchStatusForEmojiMutation();
const instanceDomain = Redux.useSelector((state) => (new URL(state.oauth.instance).host));
const [onURLChange, _resetURL, { url }] = useTextInput("url"); 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) { function submitSearch(e) {
e.preventDefault(); e.preventDefault();
if (url.trim().length != 0) { if (url.trim().length != 0) {
@ -95,101 +65,143 @@ module.exports = function ParseFromToot({ emojiCodes }) {
onChange={onURLChange} onChange={onURLChange}
value={url} value={url}
/> />
<button className="button-inline" disabled={isLoading}> <button className="button-inline" disabled={result.isLoading}>
<i className={[ <i className={[
"fa fa-fw", "fa fa-fw",
(isLoading (result.isLoading
? "fa-refresh fa-spin" ? "fa-refresh fa-spin"
: "fa-search") : "fa-search")
].join(" ")} aria-hidden="true" title="Search" /> ].join(" ")} aria-hidden="true" title="Search" />
<span className="sr-only">Search</span> <span className="sr-only">Search</span>
</button> </button>
</div> </div>
{error && <div className="error">{error.data.error}</div>}
</div> </div>
</form> </form>
{searchResult} <SearchResult result={result} localEmojiCodes={emojiCodes} />
</div> </div>
); );
}; };
function CopyEmojiForm({ localEmojiCodes, type, domain, emojiList }) { function SearchResult({ result, localEmojiCodes }) {
const [patchRemoteEmojis, patchResult] = query.usePatchRemoteEmojisMutation(); const { error, data, isSuccess, isError } = result;
const [err, setError] = React.useState();
const emojiCheckList = useCheckListInput("selectedEmoji", { if (!(isSuccess || isError)) {
entries: emojiList, return null;
uniqueKey: "shortcode" }
});
const [categoryState, resetCategory, { category }] = useComboBoxInput("category"); 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} />;
}
const buttonsInactive = emojiCheckList.someSelected 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 CopyEmojiForm({ localEmojiCodes, type, emojiList }) {
const form = {
selectedEmoji: useCheckListInput("selectedEmoji", {
entries: emojiList,
uniqueKey: "shortcode"
}),
category: useComboBoxInput("category")
};
const [formSubmit, result] = useFormSubmit(form, query.usePatchRemoteEmojisMutation(), { changedOnly: false });
console.log("action:", result.action);
const buttonsInactive = form.selectedEmoji.someSelected
? {} ? {}
: { : {
disabled: true, disabled: true,
title: "No emoji selected, cannot perform any actions" title: "No emoji selected, cannot perform any actions"
}; };
function submit(action) { // function submit(action) {
Promise.try(() => { // Promise.try(() => {
setError(null); // setError(null);
const selectedShortcodes = emojiCheckList.selectedValues.map(([shortcode, entry]) => { // const selectedShortcodes = emojiCheckList.selectedValues.map(([shortcode, entry]) => {
if (action == "copy" && !entry.valid) { // if (action == "copy" && !entry.valid) {
throw `One or more selected emoji have non-unique shortcodes (${shortcode}), unselect them or pick a different local shortcode`; // throw `One or more selected emoji have non-unique shortcodes (${shortcode}), unselect them or pick a different local shortcode`;
} // }
return { // return {
shortcode, // shortcode,
localShortcode: entry.shortcode // localShortcode: entry.shortcode
}; // };
}); // });
return patchRemoteEmojis({ // return patchRemoteEmojis({
action, // action,
domain, // domain,
list: selectedShortcodes, // list: selectedShortcodes,
category // category
}).unwrap(); // }).unwrap();
}).then(() => { // }).then(() => {
emojiCheckList.reset(); // emojiCheckList.reset();
resetCategory(); // resetCategory();
}).catch((e) => { // }).catch((e) => {
if (Array.isArray(e)) { // if (Array.isArray(e)) {
setError(e.map(([shortcode, msg]) => ( // setError(e.map(([shortcode, msg]) => (
<div key={shortcode}> // <div key={shortcode}>
{shortcode}: <span style={{ fontWeight: "initial" }}>{msg}</span> // {shortcode}: <span style={{ fontWeight: "initial" }}>{msg}</span>
</div> // </div>
))); // )));
} else { // } else {
setError(e); // setError(e);
} // }
}); // });
} // }
return ( return (
<div className="parsed"> <div className="parsed">
<span>This {type == "statuses" ? "toot" : "account"} uses the following custom emoji, select the ones you want to copy/disable:</span> <span>This {type == "statuses" ? "toot" : "account"} uses the following custom emoji, select the ones you want to copy/disable:</span>
<CheckList <form onSubmit={formSubmit}>
field={emojiCheckList} <CheckList
Component={EmojiEntry} field={form.selectedEmoji}
localEmojiCodes={localEmojiCodes} Component={EmojiEntry}
/> localEmojiCodes={localEmojiCodes}
/>
<CategorySelect <CategorySelect
value={category} field={form.category}
categoryState={categoryState} />
/>
<div className="action-buttons row"> <div className="action-buttons row">
<MutationButton label="Copy to local emoji" type="button" result={patchResult} {...buttonsInactive} /> <MutationButton name="copy" label="Copy to local emoji" result={result} showError={false} {...buttonsInactive} />
<MutationButton label="Disable" type="button" result={patchResult} className="button danger" {...buttonsInactive} /> <MutationButton name="disable" label="Disable" result={result} className="button danger" showError={false} {...buttonsInactive} />
</div> </div>
{err && <div className="error"> {result.error && (
{err} Array.isArray(result.error)
</div>} ? <ErrorList errors={result.error} />
{patchResult.isSuccess && <div> : <Error error={result.error} />
Action applied to {patchResult.data.length} emoji )}
</div>} </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> </div>
); );
} }

View File

@ -21,6 +21,7 @@
const React = require("react"); const React = require("react");
const query = require("../../lib/query"); const query = require("../../lib/query");
const processDomainList = require("../../lib/import-export");
const { const {
useTextInput, useTextInput,
@ -34,37 +35,71 @@ const {
TextInput, TextInput,
TextArea, TextArea,
Checkbox, Checkbox,
FileInput FileInput,
useCheckListInput
} = require("../../components/form/inputs"); } = require("../../components/form/inputs");
const FormWithData = require("../../lib/form/form-with-data"); const FormWithData = require("../../lib/form/form-with-data");
const CheckList = require("../../components/check-list");
const MutationButton = require("../../components/form/mutation-button");
module.exports = function ImportExport() { module.exports = function ImportExport() {
const [parsedList, setParsedList] = React.useState();
const form = {
domains: useTextInput("domains"),
obfuscate: useBoolInput("obfuscate"),
commentPrivate: useTextInput("private_comment"),
commentPublic: useTextInput("public_comment"),
// json: useFileInput("json")
};
function submitImport(e) {
e.preventDefault();
Promise.try(() => {
return processDomainList(form.domains.value);
}).then((processed) => {
setParsedList(processed);
}).catch((e) => {
console.error(e);
});
}
return ( return (
<div className="import-export"> <div className="import-export">
<h2>Import / Export</h2> <h2>Import / Export</h2>
<FormWithData <div>
dataQuery={query.useInstanceBlocksQuery} {
DataForm={ImportExportForm} parsedList
/> ? <ImportExportList list={parsedList} />
: <ImportExportForm form={form} submitImport={submitImport} />
}
</div>
</div> </div>
); );
}; };
function ImportExportForm({ data: blockedInstances }) { function ImportExportList({ list }) {
const form = { const entryCheckList = useCheckListInput("selectedDomains", {
list: useTextInput("list"), entries: list,
obfuscate: useBoolInput("obfuscate"), uniqueKey: "domain"
commentPrivate: useTextInput("private_comment"), });
commentPublic: useTextInput("public_comment"),
json: useFileInput("json")
};
return ( return (
<form> <CheckList
/>
);
}
function ImportExportForm({ form, submitImport }) {
return (
<form onSubmit={submitImport}>
<TextArea <TextArea
field={form.list} field={form.domains}
label="Domains, one per line" label="Domains, one per line (plaintext) or JSON"
placeholder={`google.com\nfacebook.com`} placeholder={`google.com\nfacebook.com`}
rows={8}
/> />
<TextArea <TextArea
@ -83,6 +118,10 @@ function ImportExportForm({ data: blockedInstances }) {
field={form.obfuscate} field={form.obfuscate}
label="Obfuscate domain in public lists" label="Obfuscate domain in public lists"
/> />
<div>
<MutationButton label="Import" result={importResult} /> {/* default form action */}
</div>
</form> </form>
); );
} }

View File

@ -25,13 +25,14 @@ const baseUrl = `/settings/admin/federation`;
const InstanceOverview = require("./overview"); const InstanceOverview = require("./overview");
const InstanceDetail = require("./detail"); const InstanceDetail = require("./detail");
const InstanceImportExport = require("./import-export");
module.exports = function Federation({ }) { module.exports = function Federation({ }) {
return ( return (
<Switch> <Switch>
{/* <Route path={`${baseUrl}/import-export`}> <Route path={`${baseUrl}/import-export`}>
<InstanceImportExport /> <InstanceImportExport />
</Route> */} </Route>
<Route path={`${baseUrl}/:domain`}> <Route path={`${baseUrl}/:domain`}>
<InstanceDetail baseUrl={baseUrl} /> <InstanceDetail baseUrl={baseUrl} />

View File

@ -72,7 +72,7 @@ module.exports = function InstanceOverview({ baseUrl }) {
<span> <span>
{blockedInstances.length} blocked instance{blockedInstances.length != 1 ? "s" : ""} {filtered > 0 && `(${filtered} filtered)`} {blockedInstances.length} blocked instance{blockedInstances.length != 1 ? "s" : ""} {filtered > 0 && `(${filtered} filtered)`}
</span> </span>
<div className="list"> <div className="list scrolling">
{filteredInstances.map((entry) => { {filteredInstances.map((entry) => {
return ( return (
<Link key={entry.domain} to={`${baseUrl}/${entry.domain}`}> <Link key={entry.domain} to={`${baseUrl}/${entry.domain}`}>
@ -90,8 +90,7 @@ module.exports = function InstanceOverview({ baseUrl }) {
</div> </div>
</div> </div>
</div> </div>
<Link to={`${baseUrl}/import-export`}><a>Or use the bulk import/export interface</a></Link>
<ImportExport />
</> </>
); );
}; };

View File

@ -26,21 +26,21 @@ const {
ComboboxPopover, ComboboxPopover,
} = require("ariakit/combobox"); } = require("ariakit/combobox");
module.exports = function ComboBox({state, items, label, placeHolder, children}) { module.exports = function ComboBox({ field, items, label, children, ...inputProps }) {
return ( return (
<div className="form-field combobox-wrapper"> <div className="form-field combobox-wrapper">
<label> <label>
{label} {label}
<div className="row"> <div className="row">
<Combobox <Combobox
state={state} state={field.state}
placeholder={placeHolder}
className="combobox input" className="combobox input"
{...inputProps}
/> />
{children} {children}
</div> </div>
</label> </label>
<ComboboxPopover state={state} className="popover"> <ComboboxPopover state={field.state} className="popover">
{items.map(([key, value]) => ( {items.map(([key, value]) => (
<ComboboxItem className="combobox-item" key={key} value={key}> <ComboboxItem className="combobox-item" key={key} value={key}>
{value} {value}

View File

@ -20,7 +20,7 @@
const React = require("react"); const React = require("react");
module.exports = function ErrorFallback({error, resetErrorBoundary}) { function ErrorFallback({ error, resetErrorBoundary }) {
return ( return (
<div className="error"> <div className="error">
<p> <p>
@ -28,7 +28,7 @@ module.exports = function ErrorFallback({error, resetErrorBoundary}) {
<a href="https://github.com/superseriousbusiness/gotosocial/issues">GoToSocial issue tracker</a> <a href="https://github.com/superseriousbusiness/gotosocial/issues">GoToSocial issue tracker</a>
{" or "} {" or "}
<a href="https://matrix.to/#/#gotosocial-help:superseriousbusiness.org">Matrix support room</a>. <a href="https://matrix.to/#/#gotosocial-help:superseriousbusiness.org">Matrix support room</a>.
<br/>Include the details below: <br />Include the details below:
</p> </p>
<pre> <pre>
{error.name}: {error.message} {error.name}: {error.message}
@ -41,4 +41,33 @@ module.exports = function ErrorFallback({error, resetErrorBoundary}) {
</p> </p>
</div> </div>
); );
}; }
function Error({ error }) {
console.error("Rendering:", error);
let message;
if (error.data != undefined) { // RTK Query error with data
if (error.status) {
message = (<>
<b>{error.status}:</b> {error.data.error}
</>);
} else {
message = error.data.error;
}
} else if (error.name != undefined || error.type != undefined) { // JS error
message = (<>
<b>{error.type && error.name}:</b> {error.message}
</>);
} else {
message = error.message ?? error;
}
return (
<div className="error">
{message}
</div>
);
}
module.exports = { ErrorFallback, Error };

View File

@ -60,7 +60,7 @@ function FileInput({ label, field, ...inputProps }) {
return ( return (
<div className="form-field file"> <div className="form-field file">
<label> <label>
{label} <div className="label">{label}</div>
<div className="file-input button">Browse</div> <div className="file-input button">Browse</div>
{infoComponent} {infoComponent}
{/* <a onClick={removeFile("header")}>remove</a> */} {/* <a onClick={removeFile("header")}>remove</a> */}

View File

@ -19,23 +19,30 @@
"use strict"; "use strict";
const React = require("react"); const React = require("react");
const { Error } = require("../error");
module.exports = function MutationButton({ label, result, disabled, ...inputProps }) { module.exports = function MutationButton({ label, result, disabled, showError = true, className = "", ...inputProps }) {
let iconClass = ""; let iconClass = "";
const targetsThisButton = result.action == inputProps.name; // can also both be undefined, which is correct
if (result.isLoading) { /* FIXME? submitting an unchanged form will never transition to isLoading,
iconClass = "fa-spin fa-refresh"; and check icon will stay faded out because the css animation doesn't restart
} else if (result.isSuccess) { */
iconClass = "fa-check fadeout"; if (targetsThisButton) {
if (result.isLoading) {
iconClass = "fa-spin fa-refresh";
} else if (result.isSuccess) {
iconClass = "fa-check fadeout";
}
} }
return (<div> return (<div>
{result.error && {(showError && targetsThisButton && result.error) &&
<section className="error">{result.error.status}: {result.error.data.error}</section> <Error error={result.error} />
} }
<button type="submit" disabled={result.isLoading || disabled} {...inputProps}> <button type="submit" className={"with-icon " + className} disabled={result.isLoading || disabled} {...inputProps}>
<i className={`fa fa-fw with-text ${iconClass}`} aria-hidden="true"></i> <i className={`fa fa-fw ${iconClass}`} aria-hidden="true"></i>
{result.isLoading {(targetsThisButton && result.isLoading)
? "Processing..." ? "Processing..."
: label : label
} }

View File

@ -12,10 +12,10 @@
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details. GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License 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/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
"use strict"; "use strict";
const Promise = require("bluebird"); const Promise = require("bluebird");
@ -24,11 +24,12 @@ const Redux = require("react-redux");
const { setInstance } = require("../redux/reducers/oauth").actions; const { setInstance } = require("../redux/reducers/oauth").actions;
const api = require("../lib/api"); const api = require("../lib/api");
const { Error } = require("./error");
module.exports = function Login({error}) { module.exports = function Login({ error }) {
const dispatch = Redux.useDispatch(); const dispatch = Redux.useDispatch();
const [ instanceField, setInstanceField ] = React.useState(""); const [instanceField, setInstanceField] = React.useState("");
const [ errorMsg, setErrorMsg ] = React.useState(); const [loginError, setLoginError] = React.useState();
const instanceFieldRef = React.useRef(""); const instanceFieldRef = React.useRef("");
React.useEffect(() => { React.useEffect(() => {
@ -65,12 +66,7 @@ module.exports = function Login({error}) {
}).then(() => { }).then(() => {
return dispatch(api.oauth.authorize()); // will send user off-page return dispatch(api.oauth.authorize()); // will send user off-page
}).catch((e) => { }).catch((e) => {
setErrorMsg( setLoginError(e);
<>
<b>{e.type}</b>
<span>{e.message}</span>
</>
);
}); });
} }
@ -89,11 +85,9 @@ module.exports = function Login({error}) {
{error} {error}
<form onSubmit={(e) => e.preventDefault()}> <form onSubmit={(e) => e.preventDefault()}>
<label htmlFor="instance">Instance: </label> <label htmlFor="instance">Instance: </label>
<input value={instanceField} onChange={updateInstanceField} id="instance"/> <input value={instanceField} onChange={updateInstanceField} id="instance" />
{errorMsg && {loginError &&
<div className="error"> <Error error={loginError} />
{errorMsg}
</div>
} }
<button onClick={tryInstance}>Authenticate</button> <button onClick={tryInstance}>Authenticate</button>
</form> </form>

View File

@ -33,6 +33,7 @@ const { AuthenticationError } = require("./lib/errors");
const Login = require("./components/login"); const Login = require("./components/login");
const Loading = require("./components/loading"); const Loading = require("./components/loading");
const { Error } = require("./components/error");
require("./style.css"); require("./style.css");
@ -103,12 +104,7 @@ function App() {
let ErrorElement = null; let ErrorElement = null;
if (errorMsg != undefined) { if (errorMsg != undefined) {
ErrorElement = ( ErrorElement = <Error error={errorMsg} />;
<div className="error">
<b>{errorMsg.type}</b>
<span>{errorMsg.message}</span>
</div>
);
} }
const LogoutElement = ( const LogoutElement = (

View File

@ -75,7 +75,7 @@ module.exports = function ({ apiCall, getChanges }) {
}); });
let defaultDate = new Date().toUTCString(); let defaultDate = new Date().toUTCString();
if (list[0] == "[") { if (list[0] == "[") {
domains = JSON.parse(state.list); domains = JSON.parse(state.list);
} else { } else {
@ -85,7 +85,7 @@ module.exports = function ({ apiCall, getChanges }) {
return null; return null;
} }
if (!isValidDomain(line, {wildcard: true, allowUnicode: true})) { if (!isValidDomain(line, { wildcard: true, allowUnicode: true })) {
invalidDomains.push(line); invalidDomains.push(line);
return null; return null;
} }
@ -103,7 +103,7 @@ module.exports = function ({ apiCall, getChanges }) {
} }
const update = { const update = {
domains: new Blob([JSON.stringify(domains)], {type: "application/json"}) domains: new Blob([JSON.stringify(domains)], { type: "application/json" })
}; };
return dispatch(apiCall("POST", "/api/v1/admin/domain_blocks?import=true", update, "form")); return dispatch(apiCall("POST", "/api/v1/admin/domain_blocks?import=true", update, "form"));

View File

@ -18,9 +18,13 @@
"use strict"; "use strict";
const React = require("react");
const { useComboboxState } = require("ariakit/combobox"); const { useComboboxState } = require("ariakit/combobox");
module.exports = function useComboBoxInput({ name, Name }, { defaultValue } = {}) { module.exports = function useComboBoxInput({ name, Name }, { defaultValue } = {}) {
const [isNew, setIsNew] = React.useState(false);
const state = useComboboxState({ const state = useComboboxState({
defaultValue, defaultValue,
gutter: 0, gutter: 0,
@ -36,11 +40,17 @@ module.exports = function useComboBoxInput({ name, Name }, { defaultValue } = {}
reset, reset,
{ {
[name]: state.value, [name]: state.value,
name name,
[`${name}IsNew`]: isNew,
[`set${Name}IsNew`]: setIsNew
} }
], { ], {
name, name,
state,
value: state.value, value: state.value,
hasChanged: () => state.value != defaultValue,
isNew,
setIsNew,
reset reset
}); });
}; };

View File

@ -6,12 +6,16 @@ const Loading = require("../../components/loading");
// Wrap Form component inside component that fires the RTK Query call, // Wrap Form component inside component that fires the RTK Query call,
// so Form will only be rendered when data is available to generate form-fields for // so Form will only be rendered when data is available to generate form-fields for
module.exports = function FormWithData({dataQuery, DataForm}) { module.exports = function FormWithData({ dataQuery, DataForm, arg }) {
const {data, isLoading} = dataQuery(); const { data, isLoading } = dataQuery(arg);
if (isLoading) { if (isLoading) {
return <Loading/>; return (
<div>
<Loading />
</div>
);
} else { } else {
return <DataForm data={data}/>; return <DataForm data={data} />;
} }
}; };

View File

@ -33,6 +33,13 @@ module.exports = {
useTextInput: makeHook(require("./text")), useTextInput: makeHook(require("./text")),
useFileInput: makeHook(require("./file")), useFileInput: makeHook(require("./file")),
useBoolInput: makeHook(require("./bool")), useBoolInput: makeHook(require("./bool")),
useComboBoxInput: makeHook(require("./combobox")), useComboBoxInput: makeHook(require("./combo-box")),
useCheckListInput: makeHook(require("./check-list")) useCheckListInput: makeHook(require("./check-list")),
useValue: function (name, value) {
return {
name,
value,
hasChanged: () => true // always included
};
}
}; };

View File

@ -18,31 +18,57 @@
"use strict"; "use strict";
const Promise = require("bluebird");
const React = require("react");
const syncpipe = require("syncpipe"); const syncpipe = require("syncpipe");
module.exports = function useFormSubmit(form, [mutationQuery, result], { changedOnly = true } = {}) { module.exports = function useFormSubmit(form, [mutationQuery, result], { changedOnly = true } = {}) {
const [usedAction, setUsedAction] = React.useState();
return [ return [
function submitForm(e) { function submitForm(e) {
e.preventDefault(); let action;
if (e?.preventDefault) {
e.preventDefault();
action = e.nativeEvent.submitter.name;
} else {
action = e;
}
setUsedAction(action);
// transform the field definitions into an object with just their values // transform the field definitions into an object with just their values
let updatedFields = []; let updatedFields = [];
const mutationData = syncpipe(form, [ const mutationData = syncpipe(form, [
(_) => Object.values(_), (_) => Object.values(_),
(_) => _.map((field) => { (_) => _.map((field) => {
if (!changedOnly || field.hasChanged()) { if (field.selectedValues != undefined) {
let selected = field.selectedValues();
if (!changedOnly || selected.length > 0) {
return [field.name, selected];
}
} else if (!changedOnly || field.hasChanged()) {
updatedFields.push(field); updatedFields.push(field);
return [field.name, field.value]; return [field.name, field.value];
} else {
return null;
} }
return null;
}), }),
(_) => _.filter((value) => value != null), (_) => _.filter((value) => value != null),
(_) => Object.fromEntries(_) (_) => Object.fromEntries(_)
]); ]);
return mutationQuery(mutationData); mutationData.action = action;
return Promise.try(() => {
return mutationQuery(mutationData);
}).then((res) => {
if (res.error == undefined) {
updatedFields.forEach((field) => {
field.reset();
});
}
});
}, },
result {
...result,
action: usedAction
}
]; ];
}; };

View File

@ -22,7 +22,7 @@ const React = require("react");
const { Link, Route, Redirect } = require("wouter"); const { Link, Route, Redirect } = require("wouter");
const { ErrorBoundary } = require("react-error-boundary"); const { ErrorBoundary } = require("react-error-boundary");
const ErrorFallback = require("../components/error"); const { ErrorFallback } = require("../components/error");
const NavButton = require("../components/nav-button"); const NavButton = require("../components/nav-button");
function urlSafe(str) { function urlSafe(str) {

View File

@ -0,0 +1,65 @@
/*
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 <http://www.gnu.org/licenses/>.
*/
"use strict";
const Promise = require("bluebird");
const isValidDomain = require("is-valid-domain");
function parseDomainList(list) {
if (list[0] == "[") {
return JSON.parse(list);
} else {
return list.split("\n").map((line) => {
let trimmed = line.trim();
return trimmed.length > 0
? { domain: trimmed }
: null;
}).filter((a) => a); // not `null`
}
}
function validateDomainList(list) {
list.forEach((entry) => {
entry.valid = isValidDomain(entry.domain, { wildcard: true, allowUnicode: true });
});
return list;
}
function deduplicateDomainList(list) {
let domains = new Set();
return list.filter((entry) => {
if (domains.has(entry.domain)) {
return false;
} else {
domains.add(entry.domain);
return true;
}
});
}
module.exports = function processDomainList(data) {
return Promise.try(() => {
return parseDomainList(data);
}).then((parsed) => {
return deduplicateDomainList(parsed);
}).then((deduped) => {
return validateDomainList(deduped);
});
};

View File

@ -0,0 +1,24 @@
/*
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 <http://www.gnu.org/licenses/>.
*/
"use strict";
module.exports = (build) => ({
// importInstanceBlocks: build.mutation({
// })
});

View File

@ -22,8 +22,8 @@ const {
replaceCacheOnMutation, replaceCacheOnMutation,
appendCacheOnMutation, appendCacheOnMutation,
spliceCacheOnMutation spliceCacheOnMutation
} = require("./lib"); } = require("../lib");
const base = require("./base"); const base = require("../base");
const endpoints = (build) => ({ const endpoints = (build) => ({
updateInstance: build.mutation({ updateInstance: build.mutation({
@ -70,7 +70,8 @@ const endpoints = (build) => ({
return draft.findIndex((block) => block.id == newData.id); return draft.findIndex((block) => block.id == newData.id);
} }
}) })
}) }),
...require("./federation-bulk")(build)
}); });
module.exports = base.injectEndpoints({ endpoints }); module.exports = base.injectEndpoints({ endpoints });

View File

@ -37,12 +37,14 @@ const endpoints = (build) => ({
? [...res.map((emoji) => ({ type: "Emojis", id: emoji.id })), { type: "Emojis", id: "LIST" }] ? [...res.map((emoji) => ({ type: "Emojis", id: emoji.id })), { type: "Emojis", id: "LIST" }]
: [{ type: "Emojis", id: "LIST" }] : [{ type: "Emojis", id: "LIST" }]
}), }),
getEmoji: build.query({ getEmoji: build.query({
query: (id) => ({ query: (id) => ({
url: `/api/v1/admin/custom_emojis/${id}` url: `/api/v1/admin/custom_emojis/${id}`
}), }),
providesTags: (res, error, id) => [{ type: "Emojis", id }] providesTags: (res, error, id) => [{ type: "Emojis", id }]
}), }),
addEmoji: build.mutation({ addEmoji: build.mutation({
query: (form) => { query: (form) => {
return { return {
@ -58,6 +60,7 @@ const endpoints = (build) => ({
? [{ type: "Emojis", id: "LIST" }, { type: "Emojis", id: res.id }] ? [{ type: "Emojis", id: "LIST" }, { type: "Emojis", id: res.id }]
: [{ type: "Emojis", id: "LIST" }] : [{ type: "Emojis", id: "LIST" }]
}), }),
editEmoji: build.mutation({ editEmoji: build.mutation({
query: ({ id, ...patch }) => { query: ({ id, ...patch }) => {
return { return {
@ -75,6 +78,7 @@ const endpoints = (build) => ({
? [{ type: "Emojis", id: "LIST" }, { type: "Emojis", id: res.id }] ? [{ type: "Emojis", id: "LIST" }, { type: "Emojis", id: res.id }]
: [{ type: "Emojis", id: "LIST" }] : [{ type: "Emojis", id: "LIST" }]
}), }),
deleteEmoji: build.mutation({ deleteEmoji: build.mutation({
query: (id) => ({ query: (id) => ({
method: "DELETE", method: "DELETE",
@ -82,75 +86,73 @@ const endpoints = (build) => ({
}), }),
invalidatesTags: (res, error, id) => [{ type: "Emojis", id }] invalidatesTags: (res, error, id) => [{ type: "Emojis", id }]
}), }),
searchStatusForEmoji: build.mutation({ searchStatusForEmoji: build.mutation({
query: (url) => ({ queryFn: (url, api, _extraOpts, baseQuery) => {
method: "GET", return Promise.try(() => {
url: `/api/v2/search?q=${encodeURIComponent(url)}&resolve=true&limit=1` return baseQuery({
}), url: `/api/v2/search?q=${encodeURIComponent(url)}&resolve=true&limit=1`
transformResponse: (res) => { }).then(unwrapRes);
/* Parses search response, prioritizing a toot result, }).then((searchRes) => {
and returns referenced custom emoji return emojiFromSearchResult(searchRes);
*/ }).then(({ type, domain, list }) => {
let type; const state = api.getState();
if (domain == new URL(state.oauth.instance).host) {
throw "LOCAL_INSTANCE";
}
if (res.statuses.length > 0) { // search for every mentioned emoji with the admin api to get their ID
type = "statuses"; return Promise.map(list, (emoji) => {
} else if (res.accounts.length > 0) {
type = "accounts";
} else {
return {
type: "none"
};
}
let data = res[type][0];
return {
type,
domain: (new URL(data.url)).host, // to get WEB_DOMAIN, see https://github.com/superseriousbusiness/gotosocial/issues/1225
list: data.emojis
};
}
}),
patchRemoteEmojis: build.mutation({
queryFn: ({ action, domain, list, category }, api, _extraOpts, baseQuery) => {
const data = [];
const errors = [];
return Promise.each(list, (emoji) => {
return Promise.try(() => {
return baseQuery({ return baseQuery({
method: "GET",
url: `/api/v1/admin/custom_emojis`, url: `/api/v1/admin/custom_emojis`,
params: { params: {
filter: `domain:${domain},shortcode:${emoji.shortcode}`, filter: `domain:${domain},shortcode:${emoji.shortcode}`,
limit: 1 limit: 1
} }
}).then(unwrapRes); }).then((unwrapRes)).then((list) => list[0]);
}).then(([lookup]) => { }, { concurrency: 5 }).then((listWithIDs) => {
if (lookup == undefined) { throw "not found"; } return {
data: {
type,
domain,
list: listWithIDs
}
};
});
}).catch((e) => {
return { error: e };
});
}
}),
patchRemoteEmojis: build.mutation({
queryFn: ({ action, ...formData }, _api, _extraOpts, baseQuery) => {
const data = [];
const errors = [];
return Promise.each(formData.selectedEmoji, (emoji) => {
return Promise.try(() => {
let body = { let body = {
type: action type: action
}; };
if (action == "copy") { if (action == "copy") {
body.shortcode = emoji.localShortcode ?? emoji.shortcode; body.shortcode = emoji.shortcode;
if (category.trim().length != 0) { if (formData.category.trim().length != 0) {
body.category = category; body.category = formData.category;
} }
} }
return baseQuery({ return baseQuery({
method: "PATCH", method: "PATCH",
url: `/api/v1/admin/custom_emojis/${lookup.id}`, url: `/api/v1/admin/custom_emojis/${emoji.id}`,
asForm: true, asForm: true,
body: body body: body
}).then(unwrapRes); }).then(unwrapRes);
}).then((res) => { }).then((res) => {
data.push([emoji.shortcode, res]); data.push([emoji.shortcode, res]);
}).catch((e) => { }).catch((e) => {
console.error("emoji lookup for", emoji.shortcode, "failed:", e); console.error("emoji", action, "for", emoji.shortcode, "failed:", e);
let msg = e.message ?? e; let msg = e.message ?? e;
if (e.data.error) { if (e.data.error) {
msg = e.data.error; msg = e.data.error;
@ -171,4 +173,27 @@ const endpoints = (build) => ({
}) })
}); });
function emojiFromSearchResult(searchRes) {
/* Parses the search response, prioritizing a toot result,
and returns referenced custom emoji
*/
let type;
if (searchRes.statuses.length > 0) {
type = "statuses";
} else if (searchRes.accounts.length > 0) {
type = "accounts";
} else {
throw "NONE_FOUND";
}
let data = searchRes[type][0];
return {
type,
domain: (new URL(data.url)).host, // to get WEB_DOMAIN, see https://github.com/superseriousbusiness/gotosocial/issues/1225
list: data.emojis
};
}
module.exports = base.injectEndpoints({ endpoints }); module.exports = base.injectEndpoints({ endpoints });

View File

@ -16,6 +16,8 @@
along with this program. If not, see <http://www.gnu.org/licenses/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
$fa-fw: 1.28571429em; /* Fork-Awesome 'fa-fw' fixed icon width */
body { body {
grid-template-rows: auto 1fr; grid-template-rows: auto 1fr;
} }
@ -225,6 +227,7 @@ section.with-sidebar > div, section.with-sidebar > form {
.button-inline { .button-inline {
width: auto; width: auto;
align-self: flex-start;
} }
input[type=checkbox] { input[type=checkbox] {
@ -344,8 +347,12 @@ form {
} }
.form-field.file label { .form-field.file label {
width: 100%; display: grid;
display: flex; grid-template-columns: auto 1fr;
.label {
grid-column: 1 / span 2;
}
} }
span.form-info { span.form-info {
@ -598,6 +605,12 @@ span.form-info {
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
div {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
img { img {
height: 8.5rem; height: 8.5rem;
width: 8.5rem; width: 8.5rem;
@ -608,15 +621,13 @@ span.form-info {
} }
.update-category { .update-category {
margin-bottom: 1rem;
.combobox-wrapper button { .combobox-wrapper button {
font-size: 1rem; font-size: 1rem;
margin: 0.15rem 0; margin: 0.15rem 0;
} }
.row { .row {
margin-top: 0.4rem; margin-top: 0.1rem;
gap: 0.5rem;
} }
} }
@ -681,8 +692,14 @@ span.form-info {
} }
} }
button .fa.with-text { button.with-icon {
margin-left: -1.28571429em; display: flex;
align-content: center;
padding-right: calc(0.5rem + $fa-fw);
.fa {
align-self: center;
}
} }
.fadeout { .fadeout {