[frontend] Custom Emoji Deletion (#994)

* re-add eslint

* fix oauth url getting too long

* actually attach single emoji get and delete routes

* basic emoji details + deletion using rtk query

* refactor emoji upload to rtk query

* clean up old redux api+reducers for custom emoji

* fix validation order

* refactor custom emoji form fields

* remove unused requires

* cleanup, fix most eslint errors

* more small eslint fixes

* fix max emoji size

* tiny bit of function documentation
This commit is contained in:
f0x52 2022-11-08 17:51:44 +01:00 committed by GitHub
parent be011b1641
commit eb25739c34
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 1467 additions and 506 deletions

View File

@ -79,6 +79,8 @@ func New(processor processing.Processor) api.ClientModule {
func (m *Module) Route(r router.Router) error {
r.AttachHandler(http.MethodPost, EmojiPath, m.EmojiCreatePOSTHandler)
r.AttachHandler(http.MethodGet, EmojiPath, m.EmojisGETHandler)
r.AttachHandler(http.MethodDelete, EmojiPathWithID, m.EmojiDELETEHandler)
r.AttachHandler(http.MethodGet, EmojiPathWithID, m.EmojiGETHandler)
r.AttachHandler(http.MethodPost, DomainBlocksPath, m.DomainBlocksPOSTHandler)
r.AttachHandler(http.MethodGet, DomainBlocksPath, m.DomainBlocksGETHandler)
r.AttachHandler(http.MethodGet, DomainBlocksPathWithID, m.DomainBlockGETHandler)

View File

@ -1,8 +1,5 @@
"use strict";
module.exports = {
"extends": ["@f0x52/eslint-config-react"],
"rules": {
"react/prop-types": "off"
}
"extends": ["@joepie91/eslint-config/react"]
};

View File

@ -261,12 +261,16 @@ section.login {
}
section.error {
word-break: break-word;
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 0.5rem;
span {
font-size: 2em;
}
pre {
border: 1px solid #ff000080;
margin-left: 1em;

View File

@ -66,6 +66,7 @@ skulk({
],
},
settings: {
debug: false,
entryFile: "settings",
outputFile: "settings.js",
prodCfg: prodCfg,

View File

@ -30,9 +30,13 @@
"@babel/preset-env": "^7.19.4",
"@babel/preset-react": "^7.18.6",
"@browserify/envify": "^6.0.0",
"@joepie91/eslint-config": "^1.1.1",
"autoprefixer": "^10.4.13",
"babelify": "^10.0.0",
"css-extract": "^2.0.0",
"eslint": "^8.26.0",
"eslint-plugin-react": "^7.31.10",
"eslint-plugin-react-hooks": "^4.6.0",
"factor-bundle": "^2.5.0",
"icssify": "^2.0.0",
"postcss": "^8.4.18",

View File

@ -18,7 +18,6 @@
"use strict";
const Promise = require("bluebird");
const React = require("react");
const Redux = require("react-redux");

View File

@ -1,229 +0,0 @@
/*
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 Submit = require("../components/submit");
const FakeToot = require("../components/fake-toot");
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 base = "/settings/admin/custom-emoji";
module.exports = function CustomEmoji() {
const dispatch = Redux.useDispatch();
const [loaded, setLoaded] = React.useState(false);
const [errorMsg, setError] = React.useState("");
React.useEffect(() => {
if (!loaded) {
Promise.try(() => {
return dispatch(api.admin.fetchCustomEmoji());
}).then(() => {
setLoaded(true);
}).catch((e) => {
setLoaded(true);
setError(e.message);
});
}
}, []);
if (!loaded) {
return (
<>
<h1>Custom Emoji</h1>
Loading...
</>
);
}
return (
<>
{errorMsg.length > 0 &&
<div className="error accent">{errorMsg}</div>
}
<Switch>
<Route path={`${base}/:emojiId`}>
<EmojiDetailWrapped />
</Route>
<EmojiOverview />
</Switch>
</>
);
};
function EmojiOverview() {
return (
<>
<h1>Custom Emoji</h1>
<EmojiList/>
<NewEmoji/>
</>
);
}
const NewEmojiForm = formFields(adminActions.updateNewEmojiVal, (state) => state.admin.newEmoji);
function NewEmoji() {
const dispatch = Redux.useDispatch();
const newEmojiForm = Redux.useSelector((state) => state.admin.newEmoji);
const [errorMsg, setError] = React.useState("");
const [statusMsg, setStatus] = React.useState("");
const uploadEmoji = submit(
() => dispatch(api.admin.newEmoji()),
{
setStatus, setError,
onSuccess: function() {
URL.revokeObjectURL(newEmojiForm.image);
return Promise.all([
dispatch(adminActions.updateNewEmojiVal(["image", undefined])),
dispatch(adminActions.updateNewEmojiVal(["imageFile", undefined])),
dispatch(adminActions.updateNewEmojiVal(["shortcode", ""])),
]);
}
}
);
React.useEffect(() => {
if (newEmojiForm.shortcode.length == 0) {
if (newEmojiForm.imageFile != undefined) {
let [name, ext] = newEmojiForm.imageFile.name.split(".");
dispatch(adminActions.updateNewEmojiVal(["shortcode", name]));
}
}
});
let emojiOrShortcode = `:${newEmojiForm.shortcode}:`;
if (newEmojiForm.image != undefined) {
emojiOrShortcode = <img
className="emoji"
src={newEmojiForm.image}
title={`:${newEmojiForm.shortcode}:`}
alt={newEmojiForm.shortcode}
/>;
}
return (
<div>
<h2>Add new custom emoji</h2>
<FakeToot>
Look at this new custom emoji {emojiOrShortcode} isn&apos;t it cool?
</FakeToot>
<NewEmojiForm.File
id="image"
name="Image"
fileType="image/png,image/gif"
showSize={true}
maxSize={50 * 1000}
/>
<NewEmojiForm.TextInput
id="shortcode"
name="Shortcode (without : :), must be unique on the instance"
placeHolder="blobcat"
/>
<Submit onClick={uploadEmoji} label="Upload" errorMsg={errorMsg} statusMsg={statusMsg} />
</div>
);
}
function EmojiList() {
const emoji = Redux.useSelector((state) => state.admin.emoji);
return (
<div>
<h2>Overview</h2>
<div className="list emoji-list">
{Object.entries(emoji).map(([category, entries]) => {
return <EmojiCategory key={category} category={category} entries={entries}/>;
})}
</div>
</div>
);
}
function EmojiCategory({category, entries}) {
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}`}> */}
<a>
<img src={e.url} alt={e.shortcode} title={`:${e.shortcode}:`}/>
</a>
</Link>
);
})}
</div>
</div>
);
}
function EmojiDetailWrapped() {
/* We wrap the component to generate formFields with a setter depending on the domain
if formFields() is used inside the same component that is re-rendered with their state,
inputs get re-created on every change, causing them to lose focus, and bad performance
*/
let [_match, {emojiId}] = useRoute(`${base}/:emojiId`);
const emojiById = Redux.useSelector((state) => state.admin.emojiById);
const emoji = emojiById[emojiId];
if (emoji == undefined) {
return (
<h1><BackButton to={base}/> Custom Emoji: </h1>
);
}
function alterEmoji([key, val]) {
return adminActions.updateDomainBlockVal([emojiId, key, val]);
}
const fields = formFields(alterEmoji, (state) => state.admin.blockedInstances[emojiId]);
return <EmojiDetail emoji={emoji} Form={fields} />;
}
function EmojiDetail({emoji, Form}) {
return (
<div>
<h1><BackButton to={base}/> Custom Emoji: {emoji.shortcode}</h1>
<p>
Editing custom emoji isn&apos;t implemented yet.<br/>
<a target="_blank" rel="noreferrer" href="https://github.com/superseriousbusiness/gotosocial/issues/797">View implementation progress.</a>
</p>
<img src={emoji.url} alt={emoji.shortcode} title={`:${emoji.shortcode}:`}/>
</div>
);
}

View File

@ -0,0 +1,86 @@
/*
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 { useRoute, Link, Redirect } = require("wouter");
const BackButton = require("../../components/back-button");
const query = require("../../lib/query");
const base = "/settings/admin/custom-emoji";
/* We wrap the component to generate formFields with a setter depending on the domain
if formFields() is used inside the same component that is re-rendered with their state,
inputs get re-created on every change, causing them to lose focus, and bad performance
*/
module.exports = function EmojiDetailWrapped() {
let [_match, {emojiId}] = useRoute(`${base}/:emojiId`);
const {currentData: emoji, isLoading, error} = query.useGetEmojiQuery(emojiId);
return (<>
{error && <div className="error accent">{error.status}: {error.data.error}</div>}
{isLoading
? "Loading..."
: <EmojiDetail emoji={emoji}/>
}
</>);
};
function EmojiDetail({emoji}) {
if (emoji == undefined) {
return (<>
<Link to={base}>
<a className="button">go back</a>
</Link>
</>);
}
return (
<div>
<h1><BackButton to={base}/> Custom Emoji: {emoji.shortcode}</h1>
<DeleteButton id={emoji.id}/>
<p>
Editing custom emoji isn&apos;t implemented yet.<br/>
<a target="_blank" rel="noreferrer" href="https://github.com/superseriousbusiness/gotosocial/issues/797">View implementation progress.</a>
</p>
<img src={emoji.url} alt={emoji.shortcode} title={`:${emoji.shortcode}:`}/>
</div>
);
}
function DeleteButton({id}) {
// TODO: confirmation dialog?
const [deleteEmoji, deleteResult] = query.useDeleteEmojiMutation();
let text = "Delete this emoji";
if (deleteResult.isLoading) {
text = "processing...";
}
if (deleteResult.isSuccess) {
return <Redirect to={base}/>;
}
return (
<button onClick={() => deleteEmoji(id)} disabled={deleteResult.isLoading}>{text}</button>
);
}

View File

@ -0,0 +1,40 @@
/*
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 {Switch, Route} = require("wouter");
const EmojiOverview = require("./overview");
const EmojiDetail = require("./detail");
const base = "/settings/admin/custom-emoji";
module.exports = function CustomEmoji() {
return (
<>
<Switch>
<Route path={`${base}/:emojiId`}>
<EmojiDetail />
</Route>
<EmojiOverview />
</Switch>
</>
);
};

View File

@ -0,0 +1,133 @@
/*
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 FakeToot = require("../../components/fake-toot");
const MutateButton = require("../../components/mutation-button");
const {
useTextInput,
useFileInput
} = require("../../components/form");
const query = require("../../lib/query");
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", {
withPreview: true,
maxSize: 50 * 1024
});
const [onShortcodeChange, resetShortcode, {shortcode, setShortcode, shortcodeRef}] = useTextInput("shortcode", {
validator: function validateShortcode(code) {
return emojiCodes.has(code)
? "Shortcode already in use"
: "";
}
});
React.useEffect(() => {
if (shortcode.length == 0) {
if (image != undefined) {
let [name, _ext] = image.name.split(".");
setShortcode(name);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [image]);
function uploadEmoji(e) {
if (e) {
e.preventDefault();
}
Promise.try(() => {
return addEmoji({
image,
shortcode
});
}).then(() => {
resetFile();
resetShortcode();
});
}
let emojiOrShortcode = `:${shortcode}:`;
if (imageURL != undefined) {
emojiOrShortcode = <img
className="emoji"
src={imageURL}
title={`:${shortcode}:`}
alt={shortcode}
/>;
}
return (
<div>
<h2>Add new custom emoji</h2>
<FakeToot>
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>
<MutateButton text="Upload emoji" result={result}/>
</form>
</div>
);
};

View File

@ -0,0 +1,99 @@
/*
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 {Link} = require("wouter");
const defaultValue = require('default-value');
const NewEmojiForm = require("./new-emoji");
const query = require("../../lib/query");
const base = "/settings/admin/custom-emoji";
module.exports = function EmojiOverview() {
const {
data: emoji,
isLoading,
error
} = query.useGetAllEmojiQuery({filter: "domain:local"});
return (
<>
<h1>Custom Emoji</h1>
{error &&
<div className="error accent">{error}</div>
}
{isLoading
? "Loading..."
: <>
<EmojiList emoji={emoji}/>
<NewEmojiForm emoji={emoji}/>
</>
}
</>
);
};
function EmojiList({emoji}) {
const byCategory = React.useMemo(() => {
const categories = {};
emoji.forEach((emoji) => {
let cat = defaultValue(emoji.category, "Unsorted");
categories[cat] = defaultValue(categories[cat], []);
categories[cat].push(emoji);
});
return categories;
}, [emoji]);
return (
<div>
<h2>Overview</h2>
<div className="list emoji-list">
{emoji.length == 0 && "No local emoji yet"}
{Object.entries(byCategory).map(([category, entries]) => {
return <EmojiCategory key={category} category={category} entries={entries}/>;
})}
</div>
</div>
);
}
function EmojiCategory({category, entries}) {
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}`}> */}
<a>
<img src={e.url} alt={e.shortcode} title={`:${e.shortcode}:`}/>
</a>
</Link>
);
})}
</div>
</div>
);
}

View File

@ -50,7 +50,7 @@ module.exports = function AdminSettings() {
return dispatch(api.admin.fetchDomainBlocks());
});
}
}, []);
}, [dispatch, loadedBlockedInstances]);
if (!loadedBlockedInstances) {
return (
@ -315,7 +315,7 @@ function InstancePage({domain, Form}) {
if (entry == undefined) {
dispatch(api.admin.getEditableDomainBlock(domain));
}
}, []);
}, [dispatch, domain, entry]);
const [errorMsg, setError] = React.useState("");
const [statusMsg, setStatus] = React.useState("");

View File

@ -18,7 +18,6 @@
"use strict";
const Promise = require("bluebird");
const React = require("react");
const Redux = require("react-redux");
@ -32,7 +31,7 @@ const adminActions = require("../redux/reducers/instances").actions;
const {
TextInput,
TextArea,
File
_File
} = require("../components/form-fields").formFields(adminActions.setAdminSettingsVal, (state) => state.instances.adminSettings);
module.exports = function AdminSettings() {

View File

@ -18,7 +18,6 @@
"use strict";
const Promise = require("bluebird");
const React = require("react");
module.exports = function ErrorFallback({error, resetErrorBoundary}) {

View File

@ -119,7 +119,7 @@ module.exports = {
field = (
<>
<label htmlFor={id} className="file-input button">Browse</label>
<span>
<span className="form-info">
{file ? file.name : "no file selected"} {size}
</span>
{/* <a onClick={removeFile("header")}>remove</a> */}

View File

@ -0,0 +1,78 @@
/*
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 prettierBytes = require("prettier-bytes");
module.exports = function useFileInput({name, _Name}, {
withPreview,
maxSize,
initialInfo = "no file selected"
}) {
const [file, setFile] = React.useState();
const [imageURL, setImageURL] = React.useState();
const [info, setInfo] = React.useState();
function onChange(e) {
let file = e.target.files[0];
setFile(file);
URL.revokeObjectURL(imageURL);
if (file != undefined) {
if (withPreview) {
setImageURL(URL.createObjectURL(file));
}
let size = prettierBytes(file.size);
if (maxSize && file.size > maxSize) {
size = <span className="error-text">{size}</span>;
}
setInfo(<>
{file.name} ({size})
</>);
} else {
setInfo();
}
}
function reset() {
URL.revokeObjectURL(imageURL);
setImageURL();
setFile();
setInfo();
}
return [
onChange,
reset,
{
[name]: file,
[`${name}URL`]: imageURL,
[`${name}Info`]: <span className="form-info">
{info
? info
: initialInfo
}
</span>
}
];
};

View File

@ -0,0 +1,36 @@
/*
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";
function capitalizeFirst(str) {
return str.slice(0,1).toUpperCase()+str.slice(1);
}
function makeHook(func) {
return (name, ...args) => func({
name,
Name: capitalizeFirst(name)
},
...args);
}
module.exports = {
useTextInput: makeHook(require("./text")),
useFileInput: makeHook(require("./file"))
};

View File

@ -0,0 +1,56 @@
/*
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");
module.exports = function useTextInput({name, Name}, {validator} = {}) {
const [text, setText] = React.useState("");
const textRef = React.useRef(null);
function onChange(e) {
let input = e.target.value;
setText(input);
if (validator) {
validator(input);
}
}
function reset() {
setText("");
}
React.useEffect(() => {
if (validator) {
textRef.current.setCustomValidity(validator(text));
textRef.current.reportValidity();
}
}, [text, textRef, validator]);
return [
onChange,
reset,
{
[name]: text,
[`${name}Ref`]: textRef,
[`set${Name}`]: setText
}
];
};

View File

@ -0,0 +1,42 @@
/*
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");
module.exports = function MutateButton({text, result}) {
let buttonText = text;
if (result.isLoading) {
buttonText = "Processing...";
}
return (<div>
{result.error &&
<section className="error">{result.error.status}: {result.error.data.error}</section>
}
<input
className="button"
type="submit"
disabled={result.isLoading}
value={buttonText}
/>
</div>
);
};

View File

@ -46,7 +46,7 @@ const nav = {
"Instance Settings": require("./admin/settings.js"),
"Actions": require("./admin/actions"),
"Federation": require("./admin/federation.js"),
"Custom Emoji": require("./admin/emoji.js"),
"Custom Emoji": require("./admin/emoji"),
}
};
@ -94,7 +94,7 @@ function App() {
console.error(e);
});
}
}, []);
}, [loginState, dispatch]);
let ErrorElement = null;
if (errorMsg != undefined) {

View File

@ -160,33 +160,6 @@ module.exports = function ({ apiCall, getChanges }) {
});
};
},
fetchCustomEmoji: function fetchCustomEmoji() {
return function (dispatch, _getState) {
return Promise.try(() => {
return dispatch(apiCall("GET", "/api/v1/admin/custom_emojis?filter=domain:local&limit=0"));
}).then((emoji) => {
return dispatch(admin.setEmoji(emoji));
});
};
},
newEmoji: function newEmoji() {
return function (dispatch, getState) {
return Promise.try(() => {
const state = getState().admin.newEmoji;
const update = getChanges(state, {
formKeys: ["shortcode"],
fileKeys: ["image"]
});
return dispatch(apiCall("POST", "/api/v1/admin/custom_emojis", update, "form"));
}).then((emoji) => {
return dispatch(admin.addEmoji(emoji));
});
};
}
};
return adminAPI;
};

View File

@ -24,14 +24,12 @@ const d = require("dotty");
const { APIError, AuthenticationError } = require("../errors");
const { setInstanceInfo, setNamedInstanceInfo } = require("../../redux/reducers/instances").actions;
const oauth = require("../../redux/reducers/oauth").actions;
function apiCall(method, route, payload, type = "json") {
return function (dispatch, getState) {
const state = getState();
let base = state.oauth.instance;
let auth = state.oauth.token;
console.log(method, base, route, "auth:", auth != undefined);
return Promise.try(() => {
let url = new URL(base);
@ -51,21 +49,7 @@ function apiCall(method, route, payload, type = "json") {
headers["Content-Type"] = "application/json";
body = JSON.stringify(payload);
} else if (type == "form") {
const formData = new FormData();
Object.entries(payload).forEach(([key, val]) => {
if (isPlainObject(val)) {
Object.entries(val).forEach(([key2, val2]) => {
if (val2 != undefined) {
formData.set(`${key}[${key2}]`, val2);
}
});
} else {
if (val != undefined) {
formData.set(key, val);
}
}
});
body = formData;
body = convertToForm(payload);
}
}
@ -100,6 +84,28 @@ function apiCall(method, route, payload, type = "json") {
};
}
/*
Takes an object with (nested) keys, and transforms it into
a FormData object to be sent over the API
*/
function convertToForm(payload) {
const formData = new FormData();
Object.entries(payload).forEach(([key, val]) => {
if (isPlainObject(val)) {
Object.entries(val).forEach(([key2, val2]) => {
if (val2 != undefined) {
formData.set(`${key}[${key2}]`, val2);
}
});
} else {
if (val != undefined) {
formData.set(key, val);
}
}
});
return formData;
}
function getChanges(state, keys) {
const { formKeys = [], fileKeys = [], renamedKeys = {} } = keys;
const update = {};
@ -129,7 +135,8 @@ function getChanges(state, keys) {
}
function getCurrentUrl() {
return `${window.location.origin}${window.location.pathname}`;
let [pre, _past] = window.location.pathname.split("/settings");
return `${window.location.origin}${pre}/settings`;
}
function fetchInstanceWithoutStore(domain) {
@ -181,5 +188,6 @@ module.exports = {
user: require("./user")(submoduleArgs),
admin: require("./admin")(submoduleArgs),
apiCall,
convertToForm,
getChanges
};

View File

@ -19,8 +19,7 @@
"use strict";
const React = require("react");
const Redux = require("react-redux");
const { Link, Route, Switch, Redirect } = require("wouter");
const { Link, Route, Redirect } = require("wouter");
const { ErrorBoundary } = require("react-error-boundary");
const ErrorFallback = require("../components/error");

View File

@ -1,134 +0,0 @@
/*
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 ReactDom = require("react-dom");
const oauthLib = require("./oauth");
module.exports = function createPanel(clientName, scope, Component) {
ReactDom.render(<Panel/>, document.getElementById("root"));
function Panel() {
const [oauth, setOauth] = React.useState();
const [hasAuth, setAuth] = React.useState(false);
const [oauthState, setOauthState] = React.useState(localStorage.getItem("oauth"));
React.useEffect(() => {
let state = localStorage.getItem("oauth");
if (state != undefined) {
state = JSON.parse(state);
let restoredOauth = oauthLib(state.config, state);
Promise.try(() => {
return restoredOauth.callback();
}).then(() => {
setAuth(true);
});
setOauth(restoredOauth);
}
}, [setAuth, setOauth]);
if (!hasAuth && oauth && oauth.isAuthorized()) {
setAuth(true);
}
if (oauth && oauth.isAuthorized()) {
return <Component oauth={oauth} />;
} else if (oauthState != undefined) {
return "processing oauth...";
} else {
return <Auth setOauth={setOauth} />;
}
}
function Auth({setOauth}) {
const [ instance, setInstance ] = React.useState("");
React.useEffect(() => {
let isStillMounted = true;
// check if current domain runs an instance
let thisUrl = new URL(window.location.origin);
thisUrl.pathname = "/api/v1/instance";
Promise.try(() => {
return fetch(thisUrl.href);
}).then((res) => {
if (res.status == 200) {
return res.json();
}
}).then((json) => {
if (json && json.uri && isStillMounted) {
setInstance(json.uri);
}
}).catch((e) => {
console.log("error checking instance response:", e);
});
return () => {
// cleanup function
isStillMounted = false;
};
}, []);
function doAuth() {
return Promise.try(() => {
return new URL(instance);
}).catch(TypeError, () => {
return new URL(`https://${instance}`);
}).then((parsedURL) => {
let url = parsedURL.toString();
let oauth = oauthLib({
instance: url,
client_name: clientName,
scope: scope,
website: window.location.href
});
setOauth(oauth);
setInstance(url);
return oauth.register().then(() => {
return oauth;
});
}).then((oauth) => {
return oauth.authorize();
}).catch((e) => {
console.log("error authenticating:", e);
});
}
function updateInstance(e) {
if (e.key == "Enter") {
doAuth();
} else {
setInstance(e.target.value);
}
}
return (
<section className="login">
<h1>OAUTH Login:</h1>
<form onSubmit={(e) => e.preventDefault()}>
<label htmlFor="instance">Instance: </label>
<input value={instance} onChange={updateInstance} id="instance"/>
<button onClick={doAuth}>Authenticate</button>
</form>
</section>
);
}
};

View File

@ -0,0 +1,55 @@
/*
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 { createApi, fetchBaseQuery } = require("@reduxjs/toolkit/query/react");
const { convertToForm } = require("../api");
function instanceBasedQuery(args, api, extraOptions) {
const state = api.getState();
const {instance, token} = state.oauth;
if (args.baseUrl == undefined) {
args.baseUrl = instance;
}
if (args.asForm) {
delete args.asForm;
args.body = convertToForm(args.body);
}
return fetchBaseQuery({
baseUrl: args.baseUrl,
prepareHeaders: (headers) => {
if (token != undefined) {
headers.set('Authorization', token);
}
headers.set("Accept", "application/json");
return headers;
},
})(args, api, extraOptions);
}
module.exports = createApi({
reducerPath: "api",
baseQuery: instanceBasedQuery,
tagTypes: ["Emojis"],
endpoints: () => ({})
});

View File

@ -0,0 +1,66 @@
/*
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 base = require("./base");
const endpoints = (build) => ({
getAllEmoji: build.query({
query: (params = {}) => ({
url: "/api/v1/admin/custom_emojis",
params: {
limit: 0,
...params
}
}),
providesTags: (res) =>
res
? [...res.map((emoji) => ({type: "Emojis", id: emoji.id})), {type: "Emojis", id: "LIST"}]
: [{type: "Emojis", id: "LIST"}]
}),
getEmoji: build.query({
query: (id) => ({
url: `/api/v1/admin/custom_emojis/${id}`
}),
providesTags: (res, error, id) => [{type: "Emojis", id}]
}),
addEmoji: build.mutation({
query: (form) => {
return {
method: "POST",
url: `/api/v1/admin/custom_emojis`,
asForm: true,
body: form
};
},
invalidatesTags: (res) =>
res
? [{type: "Emojis", id: "LIST"}, {type: "Emojis", id: res.id}]
: [{type: "Emojis", id: "LIST"}]
}),
deleteEmoji: build.mutation({
query: (id) => ({
method: "DELETE",
url: `/api/v1/admin/custom_emojis/${id}`
}),
invalidatesTags: (res, error, id) => [{type: "Emojis", id}]
})
});
module.exports = base.injectEndpoints({endpoints});

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 = {
...require("./base"),
...require("./custom-emoji.js")
};

View File

@ -18,18 +18,20 @@
"use strict";
const { createStore, combineReducers, applyMiddleware } = require("redux");
const { persistStore, persistReducer } = require("redux-persist");
const thunk = require("redux-thunk").default;
const { composeWithDevTools } = require("redux-devtools-extension");
const { combineReducers } = require("redux");
const { configureStore } = require("@reduxjs/toolkit");
const {
persistStore,
persistReducer,
FLUSH,
REHYDRATE,
PAUSE,
PERSIST,
PURGE,
REGISTER,
} = require("redux-persist");
const persistConfig = {
key: "gotosocial-settings",
storage: require("redux-persist/lib/storage").default,
stateReconciler: require("redux-persist/lib/stateReconciler/autoMergeLevel2").default,
whitelist: ["oauth"],
blacklist: ["temporary"]
};
const query = require("../lib/query/base");
const combinedReducers = combineReducers({
oauth: require("./reducers/oauth").reducer,
@ -37,13 +39,27 @@ const combinedReducers = combineReducers({
temporary: require("./reducers/temporary").reducer,
user: require("./reducers/user").reducer,
admin: require("./reducers/admin").reducer,
[query.reducerPath]: query.reducer
});
const persistedReducer = persistReducer(persistConfig, combinedReducers);
const composedEnhancer = composeWithDevTools(applyMiddleware(thunk));
const persistedReducer = persistReducer({
key: "gotosocial-settings",
storage: require("redux-persist/lib/storage").default,
stateReconciler: require("redux-persist/lib/stateReconciler/autoMergeLevel2").default,
whitelist: ["oauth"],
}, combinedReducers);
const store = configureStore({
reducer: persistedReducer,
middleware: (getDefaultMiddleware) => {
return getDefaultMiddleware({
serializableCheck: {
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER, "temporary/setScrollElement"]
}
}).concat(query.middleware);
}
});
// TODO: change to configureStore
const store = createStore(persistedReducer, composedEnhancer);
const persistor = persistStore(store);
module.exports = { store, persistor };

View File

@ -19,7 +19,6 @@
"use strict";
const { createSlice } = require("@reduxjs/toolkit");
const defaultValue = require("default-value");
function sortBlocks(blocks) {
return blocks.sort((a, b) => { // alphabetical sort
@ -35,13 +34,6 @@ function emptyBlock() {
};
}
function emptyEmojiForm() {
return {
id: Date.now(),
shortcode: ""
};
}
module.exports = createSlice({
name: "admin",
initialState: {
@ -52,10 +44,7 @@ module.exports = createSlice({
exportType: "plain",
...emptyBlock()
},
newInstanceBlocks: {},
emoji: {},
emojiById: {},
newEmoji: emptyEmojiForm()
newInstanceBlocks: {}
},
reducers: {
setBlockedInstances: (state, { payload }) => {
@ -105,34 +94,6 @@ module.exports = createSlice({
state.bulkBlock.list = Object.values(state.blockedInstances).map((entry) => {
return entry.domain;
}).join("\n");
},
setEmoji: (state, {payload}) => {
state.emoji = {};
payload.forEach((emoji) => {
if (emoji.category == undefined) {
emoji.category = "Unsorted";
}
state.emoji[emoji.category] = defaultValue(state.emoji[emoji.category], []);
state.emoji[emoji.category].push(emoji);
state.emojiById[emoji.id] = emoji;
});
},
updateNewEmojiVal: (state, { payload: [key, val] }) => {
state.newEmoji[key] = val;
},
addEmoji: (state, {payload: emoji}) => {
if (emoji.category == undefined) {
emoji.category = "Unsorted";
}
if (emoji.id == undefined) {
emoji.id = Date.now();
}
state.emoji[emoji.category] = defaultValue(state.emoji[emoji.category], []);
state.emoji[emoji.category].push(emoji);
state.emojiById[emoji.id] = emoji;
},
}
}
});

View File

@ -283,6 +283,12 @@ section.with-sidebar > div {
}
}
.form-flex {
display: flex;
flex-direction: column;
gap: 1rem;
}
.file-upload > div {
display: flex;
gap: 1rem;
@ -330,19 +336,6 @@ section.with-sidebar > div {
flex-direction: column;
justify-content: center;
div.form-field {
width: 100%;
display: flex;
span {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0.3rem 0;
}
}
h3 {
margin-top: 0;
margin-bottom: 0.5rem;
@ -363,6 +356,20 @@ section.with-sidebar > div {
font-weight: bold;
}
.form-field.file {
width: 100%;
display: flex;
}
span.form-info {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0.3rem 0;
}
.list {
display: flex;
flex-direction: column;

View File

@ -18,7 +18,6 @@
"use strict";
const Promise = require("bluebird");
const React = require("react");
const Redux = require("react-redux");

File diff suppressed because it is too large Load Diff