summaryrefslogtreecommitdiff
path: root/web/source/settings-panel
diff options
context:
space:
mode:
Diffstat (limited to 'web/source/settings-panel')
-rw-r--r--web/source/settings-panel/admin/actions.js61
-rw-r--r--web/source/settings-panel/admin/emoji.js212
-rw-r--r--web/source/settings-panel/admin/federation.js382
-rw-r--r--web/source/settings-panel/admin/settings.js110
-rw-r--r--web/source/settings-panel/components/error.jsx45
-rw-r--r--web/source/settings-panel/components/fake-toot.jsx43
-rw-r--r--web/source/settings-panel/components/form-fields.jsx167
-rw-r--r--web/source/settings-panel/components/languages.jsx98
-rw-r--r--web/source/settings-panel/components/login.jsx102
-rw-r--r--web/source/settings-panel/components/nav-button.jsx33
-rw-r--r--web/source/settings-panel/components/submit.jsx35
-rw-r--r--web/source/settings-panel/index.js178
-rw-r--r--web/source/settings-panel/lib/api/admin.js192
-rw-r--r--web/source/settings-panel/lib/api/index.js185
-rw-r--r--web/source/settings-panel/lib/api/oauth.js124
-rw-r--r--web/source/settings-panel/lib/api/user.js67
-rw-r--r--web/source/settings-panel/lib/errors.js27
-rw-r--r--web/source/settings-panel/lib/get-views.js102
-rw-r--r--web/source/settings-panel/lib/panel.js134
-rw-r--r--web/source/settings-panel/lib/submit.js48
-rw-r--r--web/source/settings-panel/redux/index.js48
-rw-r--r--web/source/settings-panel/redux/reducers/admin.js131
-rw-r--r--web/source/settings-panel/redux/reducers/instances.js42
-rw-r--r--web/source/settings-panel/redux/reducers/oauth.js52
-rw-r--r--web/source/settings-panel/redux/reducers/temporary.js32
-rw-r--r--web/source/settings-panel/redux/reducers/user.js51
-rw-r--r--web/source/settings-panel/style.css498
-rw-r--r--web/source/settings-panel/user/profile.js113
-rw-r--r--web/source/settings-panel/user/settings.js140
29 files changed, 3452 insertions, 0 deletions
diff --git a/web/source/settings-panel/admin/actions.js b/web/source/settings-panel/admin/actions.js
new file mode 100644
index 000000000..d4980d021
--- /dev/null
+++ b/web/source/settings-panel/admin/actions.js
@@ -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 Promise = require("bluebird");
+const React = require("react");
+const Redux = require("react-redux");
+
+const Submit = require("../components/submit");
+
+const api = require("../lib/api");
+const submit = require("../lib/submit");
+
+module.exports = function AdminActionPanel() {
+ const dispatch = Redux.useDispatch();
+
+ const [days, setDays] = React.useState(30);
+
+ const [errorMsg, setError] = React.useState("");
+ const [statusMsg, setStatus] = React.useState("");
+
+ const removeMedia = submit(
+ () => dispatch(api.admin.mediaCleanup(days)),
+ {setStatus, setError}
+ );
+
+ return (
+ <>
+ <h1>Admin Actions</h1>
+ <div>
+ <h2>Media cleanup</h2>
+ <p>
+ Clean up remote media older than the specified number of days.
+ If the remote instance is still online they will be refetched when needed.
+ Also cleans up unused headers and avatars from the media cache.
+ </p>
+ <div>
+ <label htmlFor="days">Days: </label>
+ <input id="days" type="number" value={days} onChange={(e) => setDays(e.target.value)}/>
+ </div>
+ <Submit onClick={removeMedia} label="Remove media" errorMsg={errorMsg} statusMsg={statusMsg} />
+ </div>
+ </>
+ );
+}; \ No newline at end of file
diff --git a/web/source/settings-panel/admin/emoji.js b/web/source/settings-panel/admin/emoji.js
new file mode 100644
index 000000000..1ef4a54a3
--- /dev/null
+++ b/web/source/settings-panel/admin/emoji.js
@@ -0,0 +1,212 @@
+/*
+ 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 base = "/settings/admin/custom-emoji";
+
+module.exports = function CustomEmoji() {
+ return (
+ <Switch>
+ <Route path={`${base}/:emojiId`}>
+ <EmojiDetailWrapped />
+ </Route>
+ <EmojiOverview />
+ </Switch>
+ );
+};
+
+function EmojiOverview() {
+ 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 (
+ <>
+ <h1>Custom Emoji</h1>
+ <EmojiList/>
+ <NewEmoji/>
+ {errorMsg.length > 0 &&
+ <div className="error accent">{errorMsg}</div>
+ }
+ </>
+ );
+}
+
+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.static_url} to={`${base}/${e.shortcode}`}>
+ <Link key={e.static_url} to={`${base}`}>
+ <a>
+ <img src={e.static_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`);
+
+ function alterEmoji([key, val]) {
+ return adminActions.updateDomainBlockVal([emojiId, key, val]);
+ }
+
+ const fields = formFields(alterEmoji, (state) => state.admin.blockedInstances[emojiId]);
+
+ return <EmojiDetail id={emojiId} Form={fields} />;
+}
+
+function EmojiDetail({id, Form}) {
+ return (
+ "Not implemented yet"
+ );
+} \ No newline at end of file
diff --git a/web/source/settings-panel/admin/federation.js b/web/source/settings-panel/admin/federation.js
new file mode 100644
index 000000000..7afc3c699
--- /dev/null
+++ b/web/source/settings-panel/admin/federation.js
@@ -0,0 +1,382 @@
+/*
+ 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 base = "/settings/admin/federation";
+
+// const {
+// TextInput,
+// TextArea,
+// File
+// } = require("../components/form-fields").formFields(adminActions.setAdminSettingsVal, (state) => state.instances.adminSettings);
+
+module.exports = function AdminSettings() {
+ const dispatch = Redux.useDispatch();
+ // const instance = Redux.useSelector(state => state.instances.adminSettings);
+ const loadedBlockedInstances = Redux.useSelector(state => state.admin.loadedBlockedInstances);
+
+ React.useEffect(() => {
+ if (!loadedBlockedInstances ) {
+ Promise.try(() => {
+ return dispatch(api.admin.fetchDomainBlocks());
+ });
+ }
+ }, []);
+
+ if (!loadedBlockedInstances) {
+ return (
+ <div>
+ <h1>Federation</h1>
+ Loading...
+ </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();
+
+ 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">
+ {Object.values(blockedInstances).filter((a) => a.domain.startsWith(filter)).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) => {
+ console.error(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>
+ );
+}
+
+function BackButton() {
+ return (
+ <Link to={base}>
+ <a className="button">&lt; back</a>
+ </Link>
+ );
+}
+
+function InstancePageWrapped() {
+ /* 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, {domain}] = useRoute(`${base}/:domain`);
+
+ if (domain == "view") { // from form field submission
+ let realDomain = (new URL(document.location)).searchParams.get("domain");
+ if (realDomain == undefined) {
+ return <Redirect to={base}/>;
+ } else {
+ domain = realDomain;
+ }
+ }
+
+ function alterDomain([key, val]) {
+ return adminActions.updateDomainBlockVal([domain, key, val]);
+ }
+
+ const fields = formFields(alterDomain, (state) => state.admin.newInstanceBlocks[domain]);
+
+ return <InstancePage domain={domain} Form={fields} />;
+}
+
+function InstancePage({domain, Form}) {
+ const dispatch = Redux.useDispatch();
+ const entry = Redux.useSelector(state => state.admin.newInstanceBlocks[domain]);
+ const [_location, setLocation] = useLocation();
+
+ React.useEffect(() => {
+ if (entry == undefined) {
+ dispatch(api.admin.getEditableDomainBlock(domain));
+ }
+ }, []);
+
+ const [errorMsg, setError] = React.useState("");
+ const [statusMsg, setStatus] = React.useState("");
+
+ if (entry == undefined) {
+ return "Loading...";
+ }
+
+ const updateBlock = submit(
+ () => dispatch(api.admin.updateDomainBlock(domain)),
+ {setStatus, setError}
+ );
+
+ const removeBlock = submit(
+ () => dispatch(api.admin.removeDomainBlock(domain)),
+ {setStatus, setError, startStatus: "Removing", successStatus: "Removed!", onSuccess: () => {
+ setLocation(base);
+ }}
+ );
+
+ return (
+ <div>
+ <h1><BackButton/> Federation settings for: {domain}</h1>
+ {entry.new && "No stored block yet, you can add one below:"}
+
+ <Form.TextArea
+ id="public_comment"
+ name="Public comment"
+ />
+
+ <Form.TextArea
+ id="private_comment"
+ name="Private comment"
+ />
+
+ <Form.Checkbox
+ id="obfuscate"
+ name="Obfuscate domain? "
+ />
+
+ <div className="messagebutton">
+ <button type="submit" onClick={updateBlock}>{entry.new ? "Add block" : "Save block"}</button>
+
+ {!entry.new &&
+ <button className="danger" onClick={removeBlock}>Remove block</button>
+ }
+
+ {errorMsg.length > 0 &&
+ <div className="error accent">{errorMsg}</div>
+ }
+ {statusMsg.length > 0 &&
+ <div className="accent">{statusMsg}</div>
+ }
+ </div>
+ </div>
+ );
+} \ No newline at end of file
diff --git a/web/source/settings-panel/admin/settings.js b/web/source/settings-panel/admin/settings.js
new file mode 100644
index 000000000..845a1f924
--- /dev/null
+++ b/web/source/settings-panel/admin/settings.js
@@ -0,0 +1,110 @@
+/*
+ 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 Submit = require("../components/submit");
+
+const api = require("../lib/api");
+const submit = require("../lib/submit");
+
+const adminActions = require("../redux/reducers/instances").actions;
+
+const {
+ TextInput,
+ TextArea,
+ File
+} = require("../components/form-fields").formFields(adminActions.setAdminSettingsVal, (state) => state.instances.adminSettings);
+
+module.exports = function AdminSettings() {
+ const dispatch = Redux.useDispatch();
+
+ const [errorMsg, setError] = React.useState("");
+ const [statusMsg, setStatus] = React.useState("");
+
+ const updateSettings = submit(
+ () => dispatch(api.admin.updateInstance()),
+ {setStatus, setError}
+ );
+
+ return (
+ <div>
+ <h1>Instance Settings</h1>
+ <TextInput
+ id="title"
+ name="Title"
+ placeHolder="My GoToSocial instance"
+ />
+
+ <TextArea
+ id="short_description"
+ name="Short description"
+ placeHolder="A small testing instance for the GoToSocial alpha."
+ />
+ <TextArea
+ id="description"
+ name="Full description"
+ placeHolder="A small testing instance for the GoToSocial alpha."
+ />
+
+ <TextInput
+ id="contact_account.username"
+ name="Contact user (local account username)"
+ placeHolder="admin"
+ />
+ <TextInput
+ id="email"
+ name="Contact email"
+ placeHolder="admin@example.com"
+ />
+
+ <TextArea
+ id="terms"
+ name="Terms & Conditions"
+ placeHolder=""
+ />
+
+ {/* <div className="file-upload">
+ <h3>Instance avatar</h3>
+ <div>
+ <img className="preview avatar" src={instance.avatar} alt={instance.avatar ? `Avatar image for the instance` : "No instance avatar image set"} />
+ <File
+ id="avatar"
+ fileType="image/*"
+ />
+ </div>
+ </div>
+
+ <div className="file-upload">
+ <h3>Instance header</h3>
+ <div>
+ <img className="preview header" src={instance.header} alt={instance.header ? `Header image for the instance` : "No instance header image set"} />
+ <File
+ id="header"
+ fileType="image/*"
+ />
+ </div>
+ </div> */}
+ <Submit onClick={updateSettings} label="Save" errorMsg={errorMsg} statusMsg={statusMsg} />
+ </div>
+ );
+}; \ No newline at end of file
diff --git a/web/source/settings-panel/components/error.jsx b/web/source/settings-panel/components/error.jsx
new file mode 100644
index 000000000..13dc686b7
--- /dev/null
+++ b/web/source/settings-panel/components/error.jsx
@@ -0,0 +1,45 @@
+/*
+ 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");
+
+module.exports = function ErrorFallback({error, resetErrorBoundary}) {
+ return (
+ <div className="error">
+ <p>
+ {"An error occured, please report this on the "}
+ <a href="https://github.com/superseriousbusiness/gotosocial/issues">GoToSocial issue tracker</a>
+ {" or "}
+ <a href="https://matrix.to/#/#gotosocial-help:superseriousbusiness.org">Matrix support room</a>.
+ <br/>Include the details below:
+ </p>
+ <pre>
+ {error.name}: {error.message}
+ </pre>
+ <pre>
+ {error.stack}
+ </pre>
+ <p>
+ <button onClick={resetErrorBoundary}>Try again</button> or <a href="">refresh the page</a>
+ </p>
+ </div>
+ );
+}; \ No newline at end of file
diff --git a/web/source/settings-panel/components/fake-toot.jsx b/web/source/settings-panel/components/fake-toot.jsx
new file mode 100644
index 000000000..f79e24eb9
--- /dev/null
+++ b/web/source/settings-panel/components/fake-toot.jsx
@@ -0,0 +1,43 @@
+/*
+ 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 Redux = require("react-redux");
+
+module.exports = function FakeToot({children}) {
+ const account = Redux.useSelector((state) => state.user.profile);
+
+ return (
+ <div className="toot expanded">
+ <div className="contentgrid">
+ <span className="avatar">
+ <img src={account.avatar} alt=""/>
+ </span>
+ <span className="displayname">{account.display_name.trim().length > 0 ? account.display_name : account.username}</span>
+ <span className="username">@{account.username}</span>
+ <div className="text">
+ <div className="content">
+ {children}
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}; \ No newline at end of file
diff --git a/web/source/settings-panel/components/form-fields.jsx b/web/source/settings-panel/components/form-fields.jsx
new file mode 100644
index 000000000..cb402c3b2
--- /dev/null
+++ b/web/source/settings-panel/components/form-fields.jsx
@@ -0,0 +1,167 @@
+/*
+ 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 Redux = require("react-redux");
+const d = require("dotty");
+const prettierBytes = require("prettier-bytes");
+
+function eventListeners(dispatch, setter, obj) {
+ return {
+ onTextChange: function (key) {
+ return function (e) {
+ dispatch(setter([key, e.target.value]));
+ };
+ },
+
+ onCheckChange: function (key) {
+ return function (e) {
+ dispatch(setter([key, e.target.checked]));
+ };
+ },
+
+ onFileChange: function (key, withPreview) {
+ return function (e) {
+ let file = e.target.files[0];
+ if (withPreview) {
+ let old = d.get(obj, key);
+ if (old != undefined) {
+ URL.revokeObjectURL(old); // no error revoking a non-Object URL as provided by instance
+ }
+ let objectURL = URL.createObjectURL(file);
+ dispatch(setter([key, objectURL]));
+ }
+ dispatch(setter([`${key}File`, file]));
+ };
+ }
+ };
+}
+
+function get(state, id, defaultVal) {
+ let value;
+ if (id.includes(".")) {
+ value = d.get(state, id);
+ } else {
+ value = state[id];
+ }
+ if (value == undefined) {
+ value = defaultVal;
+ }
+ return value;
+}
+
+// function removeFile(name) {
+// return function(e) {
+// e.preventDefault();
+// dispatch(user.setProfileVal([name, ""]));
+// dispatch(user.setProfileVal([`${name}File`, ""]));
+// };
+// }
+
+module.exports = {
+ formFields: function formFields(setter, selector) {
+ function FormField({
+ type, id, name, className="", placeHolder="", fileType="", children=null,
+ options=null, inputProps={}, withPreview=true, showSize=false, maxSize=Infinity
+ }) {
+ const dispatch = Redux.useDispatch();
+ let state = Redux.useSelector(selector);
+ let {
+ onTextChange,
+ onCheckChange,
+ onFileChange
+ } = eventListeners(dispatch, setter, state);
+
+ let field;
+ let defaultLabel = true;
+ if (type == "text") {
+ field = <input type="text" id={id} value={get(state, id, "")} placeholder={placeHolder} className={className} onChange={onTextChange(id)} {...inputProps}/>;
+ } else if (type == "textarea") {
+ field = <textarea type="text" id={id} value={get(state, id, "")} placeholder={placeHolder} className={className} onChange={onTextChange(id)} rows={8} {...inputProps}/>;
+ } else if (type == "checkbox") {
+ field = <input type="checkbox" id={id} checked={get(state, id, false)} className={className} onChange={onCheckChange(id)} {...inputProps}/>;
+ } else if (type == "select") {
+ field = (
+ <select id={id} value={get(state, id, "")} className={className} onChange={onTextChange(id)} {...inputProps}>
+ {options}
+ </select>
+ );
+ } else if (type == "file") {
+ defaultLabel = false;
+ let file = get(state, `${id}File`);
+
+ let size = null;
+ if (showSize && file) {
+ size = `(${prettierBytes(file.size)})`;
+
+ if (file.size > maxSize) {
+ size = <span className="error-text">{size}</span>;
+ }
+ }
+
+ field = (
+ <>
+ <label htmlFor={id} className="file-input button">Browse</label>
+ <span>
+ {file ? file.name : "no file selected"} {size}
+ </span>
+ {/* <a onClick={removeFile("header")}>remove</a> */}
+ <input className="hidden" id={id} type="file" accept={fileType} onChange={onFileChange(id, withPreview)} {...inputProps}/>
+ </>
+ );
+ } else {
+ defaultLabel = false;
+ field = `unsupported FormField ${type}, this is a developer error`;
+ }
+
+ let label = <label htmlFor={id}>{name}</label>;
+ return (
+ <div className={`form-field ${type}`}>
+ {defaultLabel ? label : null} {field}
+ {children}
+ </div>
+ );
+ }
+
+ return {
+ TextInput: function(props) {
+ return <FormField type="text" {...props} />;
+ },
+
+ TextArea: function(props) {
+ return <FormField type="textarea" {...props} />;
+ },
+
+ Checkbox: function(props) {
+ return <FormField type="checkbox" {...props} />;
+ },
+
+ Select: function(props) {
+ return <FormField type="select" {...props} />;
+ },
+
+ File: function(props) {
+ return <FormField type="file" {...props} />;
+ },
+ };
+ },
+
+ eventListeners
+}; \ No newline at end of file
diff --git a/web/source/settings-panel/components/languages.jsx b/web/source/settings-panel/components/languages.jsx
new file mode 100644
index 000000000..1522495da
--- /dev/null
+++ b/web/source/settings-panel/components/languages.jsx
@@ -0,0 +1,98 @@
+/*
+ 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 Languages() {
+ return <React.Fragment>
+ <option value="AF">Afrikaans</option>
+ <option value="SQ">Albanian</option>
+ <option value="AR">Arabic</option>
+ <option value="HY">Armenian</option>
+ <option value="EU">Basque</option>
+ <option value="BN">Bengali</option>
+ <option value="BG">Bulgarian</option>
+ <option value="CA">Catalan</option>
+ <option value="KM">Cambodian</option>
+ <option value="ZH">Chinese (Mandarin)</option>
+ <option value="HR">Croatian</option>
+ <option value="CS">Czech</option>
+ <option value="DA">Danish</option>
+ <option value="NL">Dutch</option>
+ <option value="EN">English</option>
+ <option value="ET">Estonian</option>
+ <option value="FJ">Fiji</option>
+ <option value="FI">Finnish</option>
+ <option value="FR">French</option>
+ <option value="KA">Georgian</option>
+ <option value="DE">German</option>
+ <option value="EL">Greek</option>
+ <option value="GU">Gujarati</option>
+ <option value="HE">Hebrew</option>
+ <option value="HI">Hindi</option>
+ <option value="HU">Hungarian</option>
+ <option value="IS">Icelandic</option>
+ <option value="ID">Indonesian</option>
+ <option value="GA">Irish</option>
+ <option value="IT">Italian</option>
+ <option value="JA">Japanese</option>
+ <option value="JW">Javanese</option>
+ <option value="KO">Korean</option>
+ <option value="LA">Latin</option>
+ <option value="LV">Latvian</option>
+ <option value="LT">Lithuanian</option>
+ <option value="MK">Macedonian</option>
+ <option value="MS">Malay</option>
+ <option value="ML">Malayalam</option>
+ <option value="MT">Maltese</option>
+ <option value="MI">Maori</option>
+ <option value="MR">Marathi</option>
+ <option value="MN">Mongolian</option>
+ <option value="NE">Nepali</option>
+ <option value="NO">Norwegian</option>
+ <option value="FA">Persian</option>
+ <option value="PL">Polish</option>
+ <option value="PT">Portuguese</option>
+ <option value="PA">Punjabi</option>
+ <option value="QU">Quechua</option>
+ <option value="RO">Romanian</option>
+ <option value="RU">Russian</option>
+ <option value="SM">Samoan</option>
+ <option value="SR">Serbian</option>
+ <option value="SK">Slovak</option>
+ <option value="SL">Slovenian</option>
+ <option value="ES">Spanish</option>
+ <option value="SW">Swahili</option>
+ <option value="SV">Swedish </option>
+ <option value="TA">Tamil</option>
+ <option value="TT">Tatar</option>
+ <option value="TE">Telugu</option>
+ <option value="TH">Thai</option>
+ <option value="BO">Tibetan</option>
+ <option value="TO">Tonga</option>
+ <option value="TR">Turkish</option>
+ <option value="UK">Ukrainian</option>
+ <option value="UR">Urdu</option>
+ <option value="UZ">Uzbek</option>
+ <option value="VI">Vietnamese</option>
+ <option value="CY">Welsh</option>
+ <option value="XH">Xhosa</option>
+ </React.Fragment>;
+};
diff --git a/web/source/settings-panel/components/login.jsx b/web/source/settings-panel/components/login.jsx
new file mode 100644
index 000000000..c67e99acd
--- /dev/null
+++ b/web/source/settings-panel/components/login.jsx
@@ -0,0 +1,102 @@
+/*
+ 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 { setInstance } = require("../redux/reducers/oauth").actions;
+const api = require("../lib/api");
+
+module.exports = function Login({error}) {
+ const dispatch = Redux.useDispatch();
+ const [ instanceField, setInstanceField ] = React.useState("");
+ const [ errorMsg, setErrorMsg ] = React.useState();
+ const instanceFieldRef = React.useRef("");
+
+ React.useEffect(() => {
+ // check if current domain runs an instance
+ let currentDomain = window.location.origin;
+ Promise.try(() => {
+ return dispatch(api.instance.fetchWithoutStore(currentDomain));
+ }).then(() => {
+ if (instanceFieldRef.current.length == 0) { // user hasn't started typing yet
+ dispatch(setInstance(currentDomain));
+ instanceFieldRef.current = currentDomain;
+ setInstanceField(currentDomain);
+ }
+ }).catch((e) => {
+ console.log("Current domain does not host a valid instance: ", e);
+ });
+ }, []);
+
+ function tryInstance() {
+ let domain = instanceFieldRef.current;
+ Promise.try(() => {
+ return dispatch(api.instance.fetchWithoutStore(domain)).catch((e) => {
+ // TODO: clearer error messages for common errors
+ console.log(e);
+ throw e;
+ });
+ }).then(() => {
+ dispatch(setInstance(domain));
+
+ return dispatch(api.oauth.register()).catch((e) => {
+ console.log(e);
+ throw e;
+ });
+ }).then(() => {
+ return dispatch(api.oauth.authorize()); // will send user off-page
+ }).catch((e) => {
+ setErrorMsg(
+ <>
+ <b>{e.type}</b>
+ <span>{e.message}</span>
+ </>
+ );
+ });
+ }
+
+ function updateInstanceField(e) {
+ if (e.key == "Enter") {
+ tryInstance(instanceField);
+ } else {
+ setInstanceField(e.target.value);
+ instanceFieldRef.current = e.target.value;
+ }
+ }
+
+ return (
+ <section className="login">
+ <h1>OAUTH Login:</h1>
+ {error}
+ <form onSubmit={(e) => e.preventDefault()}>
+ <label htmlFor="instance">Instance: </label>
+ <input value={instanceField} onChange={updateInstanceField} id="instance"/>
+ {errorMsg &&
+ <div className="error">
+ {errorMsg}
+ </div>
+ }
+ <button onClick={tryInstance}>Authenticate</button>
+ </form>
+ </section>
+ );
+}; \ No newline at end of file
diff --git a/web/source/settings-panel/components/nav-button.jsx b/web/source/settings-panel/components/nav-button.jsx
new file mode 100644
index 000000000..3c76711fb
--- /dev/null
+++ b/web/source/settings-panel/components/nav-button.jsx
@@ -0,0 +1,33 @@
+/*
+ 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, useRoute } = require("wouter");
+
+module.exports = function NavButton({href, name}) {
+ const [isActive] = useRoute(`${href}/:anything?`);
+ return (
+ <Link href={href}>
+ <a className={isActive ? "active" : ""} data-content={name}>
+ {name}
+ </a>
+ </Link>
+ );
+}; \ No newline at end of file
diff --git a/web/source/settings-panel/components/submit.jsx b/web/source/settings-panel/components/submit.jsx
new file mode 100644
index 000000000..0187fc81f
--- /dev/null
+++ b/web/source/settings-panel/components/submit.jsx
@@ -0,0 +1,35 @@
+/*
+ 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 Submit({onClick, label, errorMsg, statusMsg}) {
+ return (
+ <div className="messagebutton">
+ <button type="submit" onClick={onClick}>{ label }</button>
+ {errorMsg.length > 0 &&
+ <div className="error accent">{errorMsg}</div>
+ }
+ {statusMsg.length > 0 &&
+ <div className="accent">{statusMsg}</div>
+ }
+ </div>
+ );
+};
diff --git a/web/source/settings-panel/index.js b/web/source/settings-panel/index.js
new file mode 100644
index 000000000..34720e818
--- /dev/null
+++ b/web/source/settings-panel/index.js
@@ -0,0 +1,178 @@
+/*
+ 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/client");
+const Redux = require("react-redux");
+const { Switch, Route, Redirect } = require("wouter");
+const { Provider } = require("react-redux");
+const { PersistGate } = require("redux-persist/integration/react");
+
+const { store, persistor } = require("./redux");
+const api = require("./lib/api");
+const oauth = require("./redux/reducers/oauth").actions;
+const { AuthenticationError } = require("./lib/errors");
+
+const Login = require("./components/login");
+
+require("./style.css");
+
+// TODO: nested categories?
+const nav = {
+ "User": {
+ "Profile": require("./user/profile.js"),
+ "Settings": require("./user/settings.js"),
+ },
+ "Admin": {
+ adminOnly: true,
+ "Instance Settings": require("./admin/settings.js"),
+ "Actions": require("./admin/actions"),
+ "Federation": require("./admin/federation.js"),
+ "Custom Emoji": require("./admin/emoji.js"),
+ }
+};
+
+const { sidebar, panelRouter } = require("./lib/get-views")(nav);
+
+function App() {
+ const dispatch = Redux.useDispatch();
+
+ const { loginState, isAdmin } = Redux.useSelector((state) => state.oauth);
+ const reduxTempStatus = Redux.useSelector((state) => state.temporary.status);
+
+ const [errorMsg, setErrorMsg] = React.useState();
+ const [tokenChecked, setTokenChecked] = React.useState(false);
+
+ React.useEffect(() => {
+ if (loginState == "login" || loginState == "callback") {
+ Promise.try(() => {
+ // Process OAUTH authorization token from URL if available
+ if (loginState == "callback") {
+ let urlParams = new URLSearchParams(window.location.search);
+ let code = urlParams.get("code");
+
+ if (code == undefined) {
+ setErrorMsg(new Error("Waiting for OAUTH callback but no ?code= provided. You can try logging in again:"));
+ } else {
+ return dispatch(api.oauth.tokenize(code));
+ }
+ }
+ }).then(() => {
+ // Fetch current instance info
+ return dispatch(api.instance.fetch());
+ }).then(() => {
+ // Check currently stored auth token for validity if available
+ return dispatch(api.user.fetchAccount());
+ }).then(() => {
+ setTokenChecked(true);
+
+ return dispatch(api.oauth.checkIfAdmin());
+ }).catch((e) => {
+ if (e instanceof AuthenticationError) {
+ dispatch(oauth.remove());
+ e.message = "Stored OAUTH token no longer valid, please log in again.";
+ }
+ setErrorMsg(e);
+ console.error(e);
+ });
+ }
+ }, []);
+
+ let ErrorElement = null;
+ if (errorMsg != undefined) {
+ ErrorElement = (
+ <div className="error">
+ <b>{errorMsg.type}</b>
+ <span>{errorMsg.message}</span>
+ </div>
+ );
+ }
+
+ const LogoutElement = (
+ <button className="logout" onClick={() => { dispatch(api.oauth.logout()); }}>
+ Log out
+ </button>
+ );
+
+ if (reduxTempStatus != undefined) {
+ return (
+ <section>
+ {reduxTempStatus}
+ </section>
+ );
+ } else if (tokenChecked && loginState == "login") {
+ return (
+ <>
+ <div className="sidebar">
+ {sidebar.all}
+ {isAdmin && sidebar.admin}
+ {LogoutElement}
+ </div>
+ <section className="with-sidebar">
+ {ErrorElement}
+ <Switch>
+ {panelRouter.all}
+ {isAdmin && panelRouter.admin}
+ <Route> {/* default route */}
+ <Redirect to="/settings/user" />
+ </Route>
+ </Switch>
+ </section>
+ </>
+ );
+ } else if (loginState == "none") {
+ return (
+ <Login error={ErrorElement} />
+ );
+ } else {
+ let status;
+
+ if (loginState == "login") {
+ status = "Verifying stored login...";
+ } else if (loginState == "callback") {
+ status = "Processing OAUTH callback...";
+ }
+
+ return (
+ <section>
+ <div>
+ {status}
+ </div>
+ {ErrorElement}
+ {LogoutElement}
+ </section>
+ );
+ }
+
+}
+
+function Main() {
+ return (
+ <Provider store={store}>
+ <PersistGate loading={"loading..."} persistor={persistor}>
+ <App />
+ </PersistGate>
+ </Provider>
+ );
+}
+
+const root = ReactDom.createRoot(document.getElementById("root"));
+root.render(<React.StrictMode><Main /></React.StrictMode>); \ No newline at end of file
diff --git a/web/source/settings-panel/lib/api/admin.js b/web/source/settings-panel/lib/api/admin.js
new file mode 100644
index 000000000..1df47b693
--- /dev/null
+++ b/web/source/settings-panel/lib/api/admin.js
@@ -0,0 +1,192 @@
+/*
+ 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");
+
+const instance = require("../../redux/reducers/instances").actions;
+const admin = require("../../redux/reducers/admin").actions;
+
+module.exports = function ({ apiCall, getChanges }) {
+ const adminAPI = {
+ updateInstance: function updateInstance() {
+ return function (dispatch, getState) {
+ return Promise.try(() => {
+ const state = getState().instances.adminSettings;
+
+ const update = getChanges(state, {
+ formKeys: ["title", "short_description", "description", "contact_account.username", "email", "terms"],
+ renamedKeys: {"contact_account.username": "contact_username"},
+ // fileKeys: ["avatar", "header"]
+ });
+
+ return dispatch(apiCall("PATCH", "/api/v1/instance", update, "form"));
+ }).then((data) => {
+ return dispatch(instance.setInstanceInfo(data));
+ });
+ };
+ },
+
+ fetchDomainBlocks: function fetchDomainBlocks() {
+ return function (dispatch, _getState) {
+ return Promise.try(() => {
+ return dispatch(apiCall("GET", "/api/v1/admin/domain_blocks"));
+ }).then((data) => {
+ return dispatch(admin.setBlockedInstances(data));
+ });
+ };
+ },
+
+ updateDomainBlock: function updateDomainBlock(domain) {
+ return function (dispatch, getState) {
+ return Promise.try(() => {
+ const state = getState().admin.newInstanceBlocks[domain];
+ const update = getChanges(state, {
+ formKeys: ["domain", "obfuscate", "public_comment", "private_comment"],
+ });
+
+ return dispatch(apiCall("POST", "/api/v1/admin/domain_blocks", update, "form"));
+ }).then((block) => {
+ return Promise.all([
+ dispatch(admin.newDomainBlock([domain, block])),
+ dispatch(admin.setDomainBlock([domain, block]))
+ ]);
+ });
+ };
+ },
+
+ getEditableDomainBlock: function getEditableDomainBlock(domain) {
+ return function (dispatch, getState) {
+ let data = getState().admin.blockedInstances[domain];
+ return dispatch(admin.newDomainBlock([domain, data]));
+ };
+ },
+
+ bulkDomainBlock: function bulkDomainBlock() {
+ return function (dispatch, getState) {
+ let invalidDomains = [];
+ let success = 0;
+
+ return Promise.try(() => {
+ const state = getState().admin.bulkBlock;
+ let list = state.list;
+ let domains;
+
+ let fields = getChanges(state, {
+ formKeys: ["obfuscate", "public_comment", "private_comment"]
+ });
+
+ let defaultDate = new Date().toUTCString();
+
+ if (list[0] == "[") {
+ domains = JSON.parse(state.list);
+ } else {
+ domains = list.split("\n").map((line_) => {
+ let line = line_.trim();
+ if (line.length == 0) {
+ return null;
+ }
+
+ if (!isValidDomain(line, {wildcard: true, allowUnicode: true})) {
+ invalidDomains.push(line);
+ return null;
+ }
+
+ return {
+ domain: line,
+ created_at: defaultDate,
+ ...fields
+ };
+ }).filter((a) => a != null);
+ }
+
+ if (domains.length == 0) {
+ return;
+ }
+
+ const update = {
+ domains: new Blob([JSON.stringify(domains)], {type: "application/json"})
+ };
+
+ return dispatch(apiCall("POST", "/api/v1/admin/domain_blocks?import=true", update, "form"));
+ }).then((blocks) => {
+ if (blocks != undefined) {
+ return Promise.each(blocks, (block) => {
+ success += 1;
+ return dispatch(admin.setDomainBlock([block.domain, block]));
+ });
+ }
+ }).then(() => {
+ return {
+ success,
+ invalidDomains
+ };
+ });
+ };
+ },
+
+ removeDomainBlock: function removeDomainBlock(domain) {
+ return function (dispatch, getState) {
+ return Promise.try(() => {
+ const id = getState().admin.blockedInstances[domain].id;
+ return dispatch(apiCall("DELETE", `/api/v1/admin/domain_blocks/${id}`));
+ }).then((removed) => {
+ return dispatch(admin.removeDomainBlock(removed.domain));
+ });
+ };
+ },
+
+ mediaCleanup: function mediaCleanup(days) {
+ return function (dispatch, _getState) {
+ return Promise.try(() => {
+ return dispatch(apiCall("POST", `/api/v1/admin/media_cleanup?remote_cache_days=${days}`));
+ });
+ };
+ },
+
+ fetchCustomEmoji: function fetchCustomEmoji() {
+ return function (dispatch, _getState) {
+ return Promise.try(() => {
+ return dispatch(apiCall("GET", "/api/v1/custom_emojis"));
+ }).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;
+}; \ No newline at end of file
diff --git a/web/source/settings-panel/lib/api/index.js b/web/source/settings-panel/lib/api/index.js
new file mode 100644
index 000000000..e699011bd
--- /dev/null
+++ b/web/source/settings-panel/lib/api/index.js
@@ -0,0 +1,185 @@
+/*
+ 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 { isPlainObject } = require("is-plain-object");
+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);
+ let [path, query] = route.split("?");
+ url.pathname = path;
+ if (query != undefined) {
+ url.search = query;
+ }
+ let body;
+
+ let headers = {
+ "Accept": "application/json",
+ };
+
+ if (payload != undefined) {
+ if (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;
+ }
+ }
+
+ if (auth != undefined) {
+ headers["Authorization"] = auth;
+ }
+
+ return fetch(url.toString(), {
+ method,
+ headers,
+ body
+ });
+ }).then((res) => {
+ // try parse json even with error
+ let json = res.json().catch((e) => {
+ throw new APIError(`JSON parsing error: ${e.message}`);
+ });
+
+ return Promise.all([res, json]);
+ }).then(([res, json]) => {
+ if (!res.ok) {
+ if (auth != undefined && (res.status == 401 || res.status == 403)) {
+ // stored access token is invalid
+ throw new AuthenticationError("401: Authentication error", {json, status: res.status});
+ } else {
+ throw new APIError(json.error, { json });
+ }
+ } else {
+ return json;
+ }
+ });
+ };
+}
+
+function getChanges(state, keys) {
+ const { formKeys = [], fileKeys = [], renamedKeys = {} } = keys;
+ const update = {};
+
+ formKeys.forEach((key) => {
+ let value = d.get(state, key);
+ if (value == undefined) {
+ return;
+ }
+ if (renamedKeys[key]) {
+ key = renamedKeys[key];
+ }
+ d.put(update, key, value);
+ });
+
+ fileKeys.forEach((key) => {
+ let file = d.get(state, `${key}File`);
+ if (file != undefined) {
+ if (renamedKeys[key]) {
+ key = renamedKeys[key];
+ }
+ d.put(update, key, file);
+ }
+ });
+
+ return update;
+}
+
+function getCurrentUrl() {
+ return `${window.location.origin}${window.location.pathname}`;
+}
+
+function fetchInstanceWithoutStore(domain) {
+ return function (dispatch, getState) {
+ return Promise.try(() => {
+ let lookup = getState().instances.info[domain];
+ if (lookup != undefined) {
+ return lookup;
+ }
+
+ // apiCall expects to pull the domain from state,
+ // but we don't want to store it there yet
+ // so we mock the API here with our function argument
+ let fakeState = {
+ oauth: { instance: domain }
+ };
+
+ return apiCall("GET", "/api/v1/instance")(dispatch, () => fakeState);
+ }).then((json) => {
+ if (json && json.uri) { // TODO: validate instance json more?
+ dispatch(setNamedInstanceInfo([domain, json]));
+ return json;
+ }
+ });
+ };
+}
+
+function fetchInstance() {
+ return function (dispatch, _getState) {
+ return Promise.try(() => {
+ return dispatch(apiCall("GET", "/api/v1/instance"));
+ }).then((json) => {
+ if (json && json.uri) {
+ dispatch(setInstanceInfo(json));
+ return json;
+ }
+ });
+ };
+}
+
+let submoduleArgs = { apiCall, getCurrentUrl, getChanges };
+
+module.exports = {
+ instance: {
+ fetchWithoutStore: fetchInstanceWithoutStore,
+ fetch: fetchInstance
+ },
+ oauth: require("./oauth")(submoduleArgs),
+ user: require("./user")(submoduleArgs),
+ admin: require("./admin")(submoduleArgs),
+ apiCall,
+ getChanges
+}; \ No newline at end of file
diff --git a/web/source/settings-panel/lib/api/oauth.js b/web/source/settings-panel/lib/api/oauth.js
new file mode 100644
index 000000000..76d0e9d2f
--- /dev/null
+++ b/web/source/settings-panel/lib/api/oauth.js
@@ -0,0 +1,124 @@
+/*
+ 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 { OAUTHError, AuthenticationError } = require("../errors");
+
+const oauth = require("../../redux/reducers/oauth").actions;
+const temporary = require("../../redux/reducers/temporary").actions;
+const admin = require("../../redux/reducers/admin").actions;
+
+module.exports = function oauthAPI({ apiCall, getCurrentUrl }) {
+ return {
+
+ register: function register(scopes = []) {
+ return function (dispatch, _getState) {
+ return Promise.try(() => {
+ return dispatch(apiCall("POST", "/api/v1/apps", {
+ client_name: "GoToSocial Settings",
+ scopes: scopes.join(" "),
+ redirect_uris: getCurrentUrl(),
+ website: getCurrentUrl()
+ }));
+ }).then((json) => {
+ json.scopes = scopes;
+ dispatch(oauth.setRegistration(json));
+ });
+ };
+ },
+
+ authorize: function authorize() {
+ return function (dispatch, getState) {
+ let state = getState();
+ let reg = state.oauth.registration;
+ let base = new URL(state.oauth.instance);
+
+ base.pathname = "/oauth/authorize";
+ base.searchParams.set("client_id", reg.client_id);
+ base.searchParams.set("redirect_uri", getCurrentUrl());
+ base.searchParams.set("response_type", "code");
+ base.searchParams.set("scope", reg.scopes.join(" "));
+
+ dispatch(oauth.setLoginState("callback"));
+ dispatch(temporary.setStatus("Redirecting to instance login..."));
+
+ // send user to instance's login flow
+ window.location.assign(base.href);
+ };
+ },
+
+ tokenize: function tokenize(code) {
+ return function (dispatch, getState) {
+ let reg = getState().oauth.registration;
+
+ return Promise.try(() => {
+ if (reg == undefined || reg.client_id == undefined) {
+ throw new OAUTHError("Callback code present, but no client registration is available from localStorage. \nNote: localStorage is unavailable in Private Browsing.");
+ }
+
+ return dispatch(apiCall("POST", "/oauth/token", {
+ client_id: reg.client_id,
+ client_secret: reg.client_secret,
+ redirect_uri: getCurrentUrl(),
+ grant_type: "authorization_code",
+ code: code
+ }));
+ }).then((json) => {
+ window.history.replaceState({}, document.title, window.location.pathname);
+ return dispatch(oauth.login(json));
+ });
+ };
+ },
+
+ checkIfAdmin: function checkIfAdmin() {
+ return function (dispatch, getState) {
+ const state = getState();
+ let stored = state.oauth.isAdmin;
+ if (stored != undefined) {
+ return stored;
+ }
+
+ // newer GoToSocial version will include a `role` in the Account data, check that first
+ // TODO: check account data for admin status
+
+ // no role info, try fetching an admin-only route and see if we get an error
+ return Promise.try(() => {
+ return dispatch(apiCall("GET", "/api/v1/admin/domain_blocks"));
+ }).then((data) => {
+ return Promise.all([
+ dispatch(oauth.setAdmin(true)),
+ dispatch(admin.setBlockedInstances(data))
+ ]);
+ }).catch(AuthenticationError, () => {
+ return dispatch(oauth.setAdmin(false));
+ });
+ };
+ },
+
+ logout: function logout() {
+ return function (dispatch, _getState) {
+ // TODO: GoToSocial does not have a logout API route yet
+
+ return dispatch(oauth.remove());
+ };
+ }
+ };
+}; \ No newline at end of file
diff --git a/web/source/settings-panel/lib/api/user.js b/web/source/settings-panel/lib/api/user.js
new file mode 100644
index 000000000..18b54bd73
--- /dev/null
+++ b/web/source/settings-panel/lib/api/user.js
@@ -0,0 +1,67 @@
+/*
+ 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 user = require("../../redux/reducers/user").actions;
+
+module.exports = function ({ apiCall, getChanges }) {
+ function updateCredentials(selector, keys) {
+ return function (dispatch, getState) {
+ return Promise.try(() => {
+ const state = selector(getState());
+
+ const update = getChanges(state, keys);
+
+ return dispatch(apiCall("PATCH", "/api/v1/accounts/update_credentials", update, "form"));
+ }).then((account) => {
+ return dispatch(user.setAccount(account));
+ });
+ };
+ }
+
+ return {
+ fetchAccount: function fetchAccount() {
+ return function (dispatch, _getState) {
+ return Promise.try(() => {
+ return dispatch(apiCall("GET", "/api/v1/accounts/verify_credentials"));
+ }).then((account) => {
+ return dispatch(user.setAccount(account));
+ });
+ };
+ },
+
+ updateProfile: function updateProfile() {
+ const formKeys = ["display_name", "locked", "source", "custom_css", "source.note"];
+ const renamedKeys = {
+ "source.note": "note"
+ };
+ const fileKeys = ["header", "avatar"];
+
+ return updateCredentials((state) => state.user.profile, {formKeys, renamedKeys, fileKeys});
+ },
+
+ updateSettings: function updateProfile() {
+ const formKeys = ["source"];
+
+ return updateCredentials((state) => state.user.settings, {formKeys});
+ }
+ };
+}; \ No newline at end of file
diff --git a/web/source/settings-panel/lib/errors.js b/web/source/settings-panel/lib/errors.js
new file mode 100644
index 000000000..c2f781cb2
--- /dev/null
+++ b/web/source/settings-panel/lib/errors.js
@@ -0,0 +1,27 @@
+/*
+ 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 createError = require("create-error");
+
+module.exports = {
+ APIError: createError("APIError"),
+ OAUTHError: createError("OAUTHError"),
+ AuthenticationError: createError("AuthenticationError"),
+}; \ No newline at end of file
diff --git a/web/source/settings-panel/lib/get-views.js b/web/source/settings-panel/lib/get-views.js
new file mode 100644
index 000000000..39f627435
--- /dev/null
+++ b/web/source/settings-panel/lib/get-views.js
@@ -0,0 +1,102 @@
+/*
+ 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 Redux = require("react-redux");
+const { Link, Route, Switch, Redirect } = require("wouter");
+const { ErrorBoundary } = require("react-error-boundary");
+
+const ErrorFallback = require("../components/error");
+const NavButton = require("../components/nav-button");
+
+function urlSafe(str) {
+ return str.toLowerCase().replace(/\s+/g, "-");
+}
+
+module.exports = function getViews(struct) {
+ const sidebar = {
+ all: [],
+ admin: [],
+ };
+
+ const panelRouter = {
+ all: [],
+ admin: [],
+ };
+
+ Object.entries(struct).forEach(([name, entries]) => {
+ let sidebarEl = sidebar.all;
+ let panelRouterEl = panelRouter.all;
+
+ if (entries.adminOnly) {
+ sidebarEl = sidebar.admin;
+ panelRouterEl = panelRouter.admin;
+ delete entries.adminOnly;
+ }
+
+ let base = `/settings/${urlSafe(name)}`;
+
+ let links = [];
+
+ let firstRoute;
+
+ Object.entries(entries).forEach(([name, ViewComponent]) => {
+ let url = `${base}/${urlSafe(name)}`;
+
+ if (firstRoute == undefined) {
+ firstRoute = url;
+ }
+
+ panelRouterEl.push((
+ <Route path={`${url}/:page?`} key={url}>
+ <ErrorBoundary FallbackComponent={ErrorFallback} onReset={() => { }}>
+ {/* FIXME: implement onReset */}
+ <ViewComponent />
+ </ErrorBoundary>
+ </Route>
+ ));
+
+ links.push(
+ <NavButton key={url} href={url} name={name} />
+ );
+ });
+
+ panelRouterEl.push(
+ <Route key={base} path={base}>
+ <Redirect to={firstRoute} />
+ </Route>
+ );
+
+ sidebarEl.push(
+ <React.Fragment key={name}>
+ <Link href={firstRoute}>
+ <a>
+ <h2>{name}</h2>
+ </a>
+ </Link>
+ <nav>
+ {links}
+ </nav>
+ </React.Fragment>
+ );
+ });
+
+ return { sidebar, panelRouter };
+}; \ No newline at end of file
diff --git a/web/source/settings-panel/lib/panel.js b/web/source/settings-panel/lib/panel.js
new file mode 100644
index 000000000..df723bc74
--- /dev/null
+++ b/web/source/settings-panel/lib/panel.js
@@ -0,0 +1,134 @@
+/*
+ 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>
+ );
+ }
+}; \ No newline at end of file
diff --git a/web/source/settings-panel/lib/submit.js b/web/source/settings-panel/lib/submit.js
new file mode 100644
index 000000000..f268b5cf9
--- /dev/null
+++ b/web/source/settings-panel/lib/submit.js
@@ -0,0 +1,48 @@
+/*
+ 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");
+
+module.exports = function submit(func, {
+ setStatus, setError,
+ startStatus="PATCHing", successStatus="Saved!",
+ onSuccess,
+ onError
+}) {
+ return function() {
+ setStatus(startStatus);
+ setError("");
+ return Promise.try(() => {
+ return func();
+ }).then(() => {
+ setStatus(successStatus);
+ if (onSuccess != undefined) {
+ return onSuccess();
+ }
+ }).catch((e) => {
+ setError(e.message);
+ setStatus("");
+ console.error(e);
+ if (onError != undefined) {
+ onError(e);
+ }
+ });
+ };
+}; \ No newline at end of file
diff --git a/web/source/settings-panel/redux/index.js b/web/source/settings-panel/redux/index.js
new file mode 100644
index 000000000..e0dbe9b23
--- /dev/null
+++ b/web/source/settings-panel/redux/index.js
@@ -0,0 +1,48 @@
+/*
+ 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 { createStore, combineReducers, applyMiddleware } = require("redux");
+const { persistStore, persistReducer } = require("redux-persist");
+const thunk = require("redux-thunk").default;
+const { composeWithDevTools } = require("redux-devtools-extension");
+
+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 combinedReducers = combineReducers({
+ oauth: require("./reducers/oauth").reducer,
+ instances: require("./reducers/instances").reducer,
+ temporary: require("./reducers/temporary").reducer,
+ user: require("./reducers/user").reducer,
+ admin: require("./reducers/admin").reducer,
+});
+
+const persistedReducer = persistReducer(persistConfig, combinedReducers);
+const composedEnhancer = composeWithDevTools(applyMiddleware(thunk));
+
+const store = createStore(persistedReducer, composedEnhancer);
+const persistor = persistStore(store);
+
+module.exports = { store, persistor }; \ No newline at end of file
diff --git a/web/source/settings-panel/redux/reducers/admin.js b/web/source/settings-panel/redux/reducers/admin.js
new file mode 100644
index 000000000..20d3d748d
--- /dev/null
+++ b/web/source/settings-panel/redux/reducers/admin.js
@@ -0,0 +1,131 @@
+/*
+ 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 { createSlice } = require("@reduxjs/toolkit");
+const defaultValue = require("default-value");
+
+function sortBlocks(blocks) {
+ return blocks.sort((a, b) => { // alphabetical sort
+ return a.domain.localeCompare(b.domain);
+ });
+}
+
+function emptyBlock() {
+ return {
+ public_comment: "",
+ private_comment: "",
+ obfuscate: false
+ };
+}
+
+function emptyEmojiForm() {
+ return {
+ shortcode: ""
+ };
+}
+
+module.exports = createSlice({
+ name: "admin",
+ initialState: {
+ loadedBlockedInstances: false,
+ blockedInstances: undefined,
+ bulkBlock: {
+ list: "",
+ exportType: "plain",
+ ...emptyBlock()
+ },
+ newInstanceBlocks: {},
+ emoji: {},
+ newEmoji: emptyEmojiForm()
+ },
+ reducers: {
+ setBlockedInstances: (state, { payload }) => {
+ state.blockedInstances = {};
+ sortBlocks(payload).forEach((entry) => {
+ state.blockedInstances[entry.domain] = entry;
+ });
+ state.loadedBlockedInstances = true;
+ },
+
+ newDomainBlock: (state, { payload: [domain, data] }) => {
+ if (data == undefined) {
+ data = {
+ new: true,
+ domain,
+ ...emptyBlock()
+ };
+ }
+ state.newInstanceBlocks[domain] = data;
+ },
+
+ setDomainBlock: (state, { payload: [domain, data = {}] }) => {
+ state.blockedInstances[domain] = data;
+ },
+
+ removeDomainBlock: (state, {payload: domain}) => {
+ delete state.blockedInstances[domain];
+ },
+
+ updateDomainBlockVal: (state, { payload: [domain, key, val] }) => {
+ state.newInstanceBlocks[domain][key] = val;
+ },
+
+ updateBulkBlockVal: (state, { payload: [key, val] }) => {
+ state.bulkBlock[key] = val;
+ },
+
+ resetBulkBlockVal: (state, { _payload }) => {
+ state.bulkBlock = {
+ list: "",
+ exportType: "plain",
+ ...emptyBlock()
+ };
+ },
+
+ exportToField: (state, { _payload }) => {
+ 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);
+ });
+ },
+
+ updateNewEmojiVal: (state, { payload: [key, val] }) => {
+ state.newEmoji[key] = val;
+ },
+
+ addEmoji: (state, {payload: emoji}) => {
+ if (emoji.category == undefined) {
+ emoji.category = "Unsorted";
+ }
+ state.emoji[emoji.category] = defaultValue(state.emoji[emoji.category], []);
+ state.emoji[emoji.category].push(emoji);
+ },
+ }
+}); \ No newline at end of file
diff --git a/web/source/settings-panel/redux/reducers/instances.js b/web/source/settings-panel/redux/reducers/instances.js
new file mode 100644
index 000000000..3ad5bb7cb
--- /dev/null
+++ b/web/source/settings-panel/redux/reducers/instances.js
@@ -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 {createSlice} = require("@reduxjs/toolkit");
+const d = require("dotty");
+
+module.exports = createSlice({
+ name: "instances",
+ initialState: {
+ info: {},
+ },
+ reducers: {
+ setNamedInstanceInfo: (state, {payload}) => {
+ let [key, info] = payload;
+ state.info[key] = info;
+ },
+ setInstanceInfo: (state, {payload}) => {
+ state.current = payload;
+ state.adminSettings = payload;
+ },
+ setAdminSettingsVal: (state, {payload: [key, val]}) => {
+ d.put(state.adminSettings, key, val);
+ }
+ }
+}); \ No newline at end of file
diff --git a/web/source/settings-panel/redux/reducers/oauth.js b/web/source/settings-panel/redux/reducers/oauth.js
new file mode 100644
index 000000000..c332a7d06
--- /dev/null
+++ b/web/source/settings-panel/redux/reducers/oauth.js
@@ -0,0 +1,52 @@
+/*
+ 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 {createSlice} = require("@reduxjs/toolkit");
+
+module.exports = createSlice({
+ name: "oauth",
+ initialState: {
+ loginState: 'none',
+ },
+ reducers: {
+ setInstance: (state, {payload}) => {
+ state.instance = payload;
+ },
+ setRegistration: (state, {payload}) => {
+ state.registration = payload;
+ },
+ setLoginState: (state, {payload}) => {
+ state.loginState = payload;
+ },
+ login: (state, {payload}) => {
+ state.token = `${payload.token_type} ${payload.access_token}`;
+ state.loginState = "login";
+ },
+ remove: (state, {_payload}) => {
+ delete state.token;
+ delete state.registration;
+ delete state.isAdmin;
+ state.loginState = "none";
+ },
+ setAdmin: (state, {payload}) => {
+ state.isAdmin = payload;
+ }
+ }
+}); \ No newline at end of file
diff --git a/web/source/settings-panel/redux/reducers/temporary.js b/web/source/settings-panel/redux/reducers/temporary.js
new file mode 100644
index 000000000..c887d2eee
--- /dev/null
+++ b/web/source/settings-panel/redux/reducers/temporary.js
@@ -0,0 +1,32 @@
+/*
+ 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 {createSlice} = require("@reduxjs/toolkit");
+
+module.exports = createSlice({
+ name: "temporary",
+ initialState: {
+ },
+ reducers: {
+ setStatus: function(state, {payload}) {
+ state.status = payload;
+ }
+ }
+}); \ No newline at end of file
diff --git a/web/source/settings-panel/redux/reducers/user.js b/web/source/settings-panel/redux/reducers/user.js
new file mode 100644
index 000000000..b4463c9f9
--- /dev/null
+++ b/web/source/settings-panel/redux/reducers/user.js
@@ -0,0 +1,51 @@
+/*
+ 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 { createSlice } = require("@reduxjs/toolkit");
+const d = require("dotty");
+const defaultValue = require("default-value");
+
+module.exports = createSlice({
+ name: "user",
+ initialState: {
+ profile: {},
+ settings: {}
+ },
+ reducers: {
+ setAccount: (state, { payload }) => {
+ payload.source = defaultValue(payload.source, {});
+ payload.source.language = defaultValue(payload.source.language.toUpperCase(), "EN");
+ payload.source.status_format = defaultValue(payload.source.status_format, "plain");
+ payload.source.sensitive = defaultValue(payload.source.sensitive, false);
+
+ state.profile = payload;
+ // /user/settings only needs a copy of the 'source' obj
+ state.settings = {
+ source: payload.source
+ };
+ },
+ setProfileVal: (state, { payload: [key, val] }) => {
+ d.put(state.profile, key, val);
+ },
+ setSettingsVal: (state, { payload: [key, val] }) => {
+ d.put(state.settings, key, val);
+ }
+ }
+}); \ No newline at end of file
diff --git a/web/source/settings-panel/style.css b/web/source/settings-panel/style.css
new file mode 100644
index 000000000..35d11fa08
--- /dev/null
+++ b/web/source/settings-panel/style.css
@@ -0,0 +1,498 @@
+/*
+ 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/>.
+*/
+
+body {
+ grid-template-rows: auto 1fr;
+}
+
+.content {
+ grid-column: 1 / span 3; /* stretch entire width, to fit panel + sidebar nav */
+}
+
+section {
+ grid-column: 2;
+}
+
+#root {
+ display: grid;
+ grid-template-columns: 1fr 90ch 1fr;
+ width: 100vw;
+ max-width: 100vw;
+ box-sizing: border-box;
+
+ section.with-sidebar {
+ border-left: none;
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+
+ & > div {
+ border-left: 0.2rem solid $border-accent;
+ padding-left: 0.4rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ margin: 2rem 0;
+
+ h2 {
+ margin: 0;
+ margin-bottom: 0.5rem;
+ }
+
+ &:only-child {
+ border-left: none;
+ }
+
+ &:first-child {
+ margin-top: 0;
+ }
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+ }
+
+ .sidebar {
+ align-self: start;
+ justify-self: end;
+ background: $settings-nav-bg;
+ border: $boxshadow-border;
+ box-shadow: $boxshadow;
+ border-radius: $br;
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+ display: flex;
+ flex-direction: column;
+ min-width: 12rem;
+
+ a {
+ text-decoration: none;
+ }
+
+ a:first-child h2 {
+ border-top-left-radius: $br;
+ }
+
+ h2 {
+ margin: 0;
+ padding: 0.5rem;
+ font-size: 0.9rem;
+ font-weight: bold;
+ text-transform: uppercase;
+ color: $settings-nav-header-fg;
+ background: $settings-nav-header-bg;
+ }
+
+ nav {
+ display: flex;
+ flex-direction: column;
+
+ a {
+ padding: 1rem;
+ text-decoration: none;
+ transition: 0.1s;
+ color: $fg;
+
+ &:hover {
+ color: $settings-nav-fg-hover;
+ background: $settings-nav-bg-hover;
+ }
+
+ &.active {
+ color: $settings-nav-fg-active;
+ background: $settings-nav-bg-active;
+ font-weight: bold;
+ text-decoration: underline;
+ }
+
+ /* reserve space for bold version of the element, so .active doesn't
+ change container size */
+ &::after {
+ font-weight: bold;
+ text-decoration: underline;
+ display: block;
+ content: attr(data-content);
+ height: 1px;
+ color: transparent;
+ overflow: hidden;
+ visibility: hidden;
+ }
+ }
+ }
+
+
+ nav:last-child a:last-child {
+ border-bottom-left-radius: $br;
+ border-bottom: none;
+ }
+ }
+}
+
+.capitalize {
+ text-transform: capitalize;
+}
+
+section {
+ margin-bottom: 1rem;
+}
+
+input, select, textarea {
+ box-sizing: border-box;
+}
+
+.error {
+ color: $error-fg;
+ background: $error-bg;
+ border: 0.02rem solid $error-fg;
+ border-radius: $br;
+ font-weight: bold;
+ padding: 0.5rem;
+ white-space: pre-wrap;
+
+ a {
+ color: $error-link;
+ }
+
+ pre {
+ background: $bg;
+ color: $fg;
+ padding: 1rem;
+ overflow: auto;
+ margin: 0;
+ }
+}
+
+.hidden {
+ display: none;
+}
+
+.messagebutton, .messagebutton > div {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+
+ div.padded {
+ margin-left: 1rem;
+ }
+
+ button, .button {
+ white-space: nowrap;
+ margin-right: 1rem;
+ }
+}
+
+.messagebutton > div {
+ button, .button {
+ margin-top: 1rem;
+ }
+}
+
+.notImplemented {
+ border: 2px solid rgb(70, 79, 88);
+ background: repeating-linear-gradient(
+ -45deg,
+ #525c66,
+ #525c66 10px,
+ rgb(70, 79, 88) 10px,
+ rgb(70, 79, 88) 20px
+ ) !important;
+}
+
+section.with-sidebar > div {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+
+ input, textarea {
+ width: 100%;
+ line-height: 1.5rem;
+ }
+
+ input[type=checkbox] {
+ justify-self: start;
+ width: initial;
+ }
+
+ input:read-only {
+ border: none;
+ }
+
+ input:invalid {
+ border-color: red;
+ }
+
+ textarea {
+ width: 100%;
+ }
+
+ h1 {
+ margin-bottom: 0.5rem;
+ }
+
+ .moreinfolink {
+ font-size: 0.9em;
+ }
+
+ .labelinput .border {
+ border-radius: 0.2rem;
+ border: 0.15rem solid $border_accent;
+ padding: 0.3rem;
+ display: flex;
+ flex-direction: column;
+ }
+
+ .file-input.button {
+ display: inline-block;
+ font-size: 1rem;
+ font-weight: normal;
+ padding: 0.3rem 0.3rem;
+ align-self: flex-start;
+ margin-right: 0.2rem;
+ }
+
+ .labelinput, .labelselect {
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+ }
+
+ .labelcheckbox {
+ display: flex;
+ gap: 0.4rem;
+ }
+
+ .titlesave {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem;
+ }
+}
+
+.file-upload > div {
+ display: flex;
+ gap: 1rem;
+
+ img {
+ height: 8rem;
+ border: 0.2rem solid $border-accent;
+ }
+
+ img.avatar {
+ width: 8rem;
+ }
+
+ img.header {
+ width: 24rem;
+ }
+}
+
+.user-profile {
+ .overview {
+ display: grid;
+ grid-template-columns: 70% 30%;
+
+ .basic {
+ margin-top: -4.5rem;
+
+ .avatar {
+ height: 5rem;
+ width: 5rem;
+ }
+
+ .displayname {
+ font-size: 1.3rem;
+ padding-top: 0;
+ padding-bottom: 0;
+ margin-top: 0.7rem;
+ }
+ }
+
+ .files {
+ width: 100%;
+ margin: 1rem;
+ margin-right: 0;
+ display: flex;
+ 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;
+ }
+
+ div:first-child {
+ margin-bottom: 1rem;
+ }
+
+ span {
+ font-style: italic;
+ }
+ }
+ }
+}
+
+.form-field label {
+ font-weight: bold;
+}
+
+.list {
+ display: flex;
+ flex-direction: column;
+ margin-top: 0.5rem;
+ max-height: 40rem;
+ overflow: auto;
+
+ .entry {
+ display: flex;
+ flex-wrap: wrap;
+ background: $settings-entry-bg;
+
+ &:hover {
+ background: $settings-entry-hover-bg;
+ }
+ }
+}
+
+.instance-list {
+ .filter {
+ display: flex;
+ gap: 0.5rem;
+
+ input {
+ width: auto;
+ flex: 1 1 auto;
+ }
+ }
+
+ .entry {
+ padding: 0.3rem;
+ margin: 0.2rem 0;
+
+ #domain {
+ flex: 1 1 auto;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+ }
+}
+
+.bulk h2 {
+ display: flex;
+ justify-content: space-between;
+}
+
+.emoji-list {
+ background: $settings-entry-bg;
+
+ .entry {
+ padding: 0.5rem;
+ flex-direction: column;
+
+ .emoji-group {
+ display: flex;
+
+ a {
+ border-radius: $br;
+ padding: 0.4rem;
+ line-height: 0;
+
+ img {
+ height: 2rem;
+ width: 2rem;
+ object-fit: contain;
+ vertical-align: middle;
+ }
+
+ &:hover {
+ background: $settings-entry-hover-bg;
+ }
+ }
+ }
+
+ &:hover {
+ background: inherit;
+ }
+ }
+}
+
+.toot {
+ padding-top: 0.5rem;
+ .contentgrid {
+ padding: 0 0.5rem;
+ }
+}
+
+@media screen and (max-width: 100ch) {
+ #root {
+ padding: 1rem;
+ grid-template-columns: 100%;
+ grid-template-rows: auto auto;
+
+ .sidebar {
+ justify-self: auto;
+ margin-bottom: 2rem;
+ }
+
+ .sidebar, section.with-sidebar {
+ border-top-left-radius: $br;
+ border-top-right-radius: $br;
+ border-bottom-left-radius: $br;
+ border-bottom-right-radius: $br;
+ }
+
+ .sidebar a:first-child h2 {
+ border-top-right-radius: $br;
+ }
+ }
+
+ section {
+ grid-column: 1;
+ }
+
+ .user-profile .overview {
+ grid-template-columns: 100%;
+ grid-template-rows: auto auto;
+
+ .files {
+ margin: 0;
+ margin-top: 1rem;
+ }
+ }
+
+ main section {
+ padding: 0.75rem;
+ }
+
+ .instance-list .filter {
+ flex-direction: column;
+ }
+} \ No newline at end of file
diff --git a/web/source/settings-panel/user/profile.js b/web/source/settings-panel/user/profile.js
new file mode 100644
index 000000000..7cf3a7b52
--- /dev/null
+++ b/web/source/settings-panel/user/profile.js
@@ -0,0 +1,113 @@
+/*
+ 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 Submit = require("../components/submit");
+
+const api = require("../lib/api");
+const user = require("../redux/reducers/user").actions;
+const submit = require("../lib/submit");
+
+const { formFields } = require("../components/form-fields");
+
+const {
+ TextInput,
+ TextArea,
+ Checkbox,
+ File
+} = formFields(user.setProfileVal, (state) => state.user.profile);
+
+module.exports = function UserProfile() {
+ const dispatch = Redux.useDispatch();
+ const account = Redux.useSelector(state => state.user.profile);
+ const instance = Redux.useSelector(state => state.instances.current);
+
+ const allowCustomCSS = instance.configuration.accounts.allow_custom_css;
+
+ const [errorMsg, setError] = React.useState("");
+ const [statusMsg, setStatus] = React.useState("");
+
+ const saveProfile = submit(
+ () => dispatch(api.user.updateProfile()),
+ {setStatus, setError}
+ );
+
+ return (
+ <div className="user-profile">
+ <h1>Profile</h1>
+ <div className="overview">
+ <div className="profile">
+ <div className="headerimage">
+ <img className="headerpreview" src={account.header} alt={account.header ? `header image for ${account.username}` : "None set"} />
+ </div>
+ <div className="basic">
+ <div id="profile-basic-filler2"></div>
+ <span className="avatar"><img className="avatarpreview" src={account.avatar} alt={account.avatar ? `avatar image for ${account.username}` : "None set"} /></span>
+ <div className="displayname">{account.display_name.trim().length > 0 ? account.display_name : account.username}</div>
+ <div className="username"><span>@{account.username}</span></div>
+ </div>
+ </div>
+ <div className="files">
+ <div>
+ <h3>Header</h3>
+ <File
+ id="header"
+ fileType="image/*"
+ />
+ </div>
+ <div>
+ <h3>Avatar</h3>
+ <File
+ id="avatar"
+ fileType="image/*"
+ />
+ </div>
+ </div>
+ </div>
+ <TextInput
+ id="display_name"
+ name="Name"
+ placeHolder="A GoToSocial user"
+ />
+ <TextArea
+ id="source.note"
+ name="Bio"
+ placeHolder="Just trying out GoToSocial, my pronouns are they/them and I like sloths."
+ />
+ <Checkbox
+ id="locked"
+ name="Manually approve follow requests? "
+ />
+ { !allowCustomCSS ? null :
+ <TextArea
+ id="custom_css"
+ name="Custom CSS"
+ className="monospace"
+ >
+ <a href="https://docs.gotosocial.org/en/latest/user_guide/custom_css" target="_blank" className="moreinfolink" rel="noreferrer">Learn more about custom profile CSS (opens in a new tab)</a>
+ </TextArea>
+ }
+ <Submit onClick={saveProfile} label="Save profile info" errorMsg={errorMsg} statusMsg={statusMsg} />
+ </div>
+ );
+}; \ No newline at end of file
diff --git a/web/source/settings-panel/user/settings.js b/web/source/settings-panel/user/settings.js
new file mode 100644
index 000000000..ccb3e911d
--- /dev/null
+++ b/web/source/settings-panel/user/settings.js
@@ -0,0 +1,140 @@
+/*
+ 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 api = require("../lib/api");
+const user = require("../redux/reducers/user").actions;
+const submit = require("../lib/submit");
+
+const Languages = require("../components/languages");
+const Submit = require("../components/submit");
+
+const {
+ Checkbox,
+ Select,
+} = require("../components/form-fields").formFields(user.setSettingsVal, (state) => state.user.settings);
+
+module.exports = function UserSettings() {
+ const dispatch = Redux.useDispatch();
+
+ const [errorMsg, setError] = React.useState("");
+ const [statusMsg, setStatus] = React.useState("");
+
+ const updateSettings = submit(
+ () => dispatch(api.user.updateSettings()),
+ {setStatus, setError}
+ );
+
+ return (
+ <>
+ <div className="user-settings">
+ <h1>Post settings</h1>
+ <Select id="source.language" name="Default post language" options={
+ <Languages/>
+ }>
+ </Select>
+ <Select id="source.privacy" name="Default post privacy" options={
+ <>
+ <option value="private">Private / followers-only</option>
+ <option value="unlisted">Unlisted</option>
+ <option value="public">Public</option>
+ </>
+ }>
+ <a href="https://docs.gotosocial.org/en/latest/user_guide/posts/#privacy-settings" target="_blank" className="moreinfolink" rel="noreferrer">Learn more about post privacy settings (opens in a new tab)</a>
+ </Select>
+ <Select id="source.status_format" name="Default post format" options={
+ <>
+ <option value="plain">Plain (default)</option>
+ <option value="markdown">Markdown</option>
+ </>
+ }>
+ <a href="https://docs.gotosocial.org/en/latest/user_guide/posts/#input-types" target="_blank" className="moreinfolink" rel="noreferrer">Learn more about post format settings (opens in a new tab)</a>
+ </Select>
+ <Checkbox
+ id="source.sensitive"
+ name="Mark my posts as sensitive by default"
+ />
+
+ <Submit onClick={updateSettings} label="Save post settings" errorMsg={errorMsg} statusMsg={statusMsg}/>
+ </div>
+ <div>
+ <PasswordChange/>
+ </div>
+ </>
+ );
+};
+
+function PasswordChange() {
+ const dispatch = Redux.useDispatch();
+
+ const [errorMsg, setError] = React.useState("");
+ const [statusMsg, setStatus] = React.useState("");
+
+ const [oldPassword, setOldPassword] = React.useState("");
+ const [newPassword, setNewPassword] = React.useState("");
+ const [newPasswordConfirm, setNewPasswordConfirm] = React.useState("");
+
+ function changePassword() {
+ if (newPassword !== newPasswordConfirm) {
+ setError("New password and confirm new password did not match!");
+ return;
+ }
+
+ setStatus("PATCHing");
+ setError("");
+ return Promise.try(() => {
+ let data = {
+ old_password: oldPassword,
+ new_password: newPassword
+ };
+ return dispatch(api.apiCall("POST", "/api/v1/user/password_change", data, "form"));
+ }).then(() => {
+ setStatus("Saved!");
+ setOldPassword("");
+ setNewPassword("");
+ setNewPasswordConfirm("");
+ }).catch((e) => {
+ setError(e.message);
+ setStatus("");
+ });
+ }
+
+ return (
+ <>
+ <h1>Change password</h1>
+ <div className="labelinput">
+ <label htmlFor="password">Current password</label>
+ <input name="password" id="password" type="password" autoComplete="current-password" value={oldPassword} onChange={(e) => setOldPassword(e.target.value)} />
+ </div>
+ <div className="labelinput">
+ <label htmlFor="new-password">New password</label>
+ <input name="new-password" id="new-password" type="password" autoComplete="new-password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
+ </div>
+ <div className="labelinput">
+ <label htmlFor="confirm-new-password">Confirm new password</label>
+ <input name="confirm-new-password" id="confirm-new-password" type="password" autoComplete="new-password" value={newPasswordConfirm} onChange={(e) => setNewPasswordConfirm(e.target.value)} />
+ </div>
+ <Submit onClick={changePassword} label="Save new password" errorMsg={errorMsg} statusMsg={statusMsg}/>
+ </>
+ );
+} \ No newline at end of file