[feature/frogend] (Mastodon) domain block CSV import (#1390)

* checkbox-list styling with taller <p> element

* CSV import/export, UI/UX improvements to import-export interface

* minor styling tweaks

* csv export, clean up export type branching

* abstract domain block entry validation

* foundation for PSL check + suggestions

* Squashed commit of the following:

commit e3655ba4fb
Author: f0x <f0x@cthu.lu>
Date:   Tue Jan 31 15:19:10 2023 +0100

    let debug depend on env (prod/debug) again

commit 79c792b832
Author: f0x <f0x@cthu.lu>
Date:   Tue Jan 31 00:34:01 2023 +0100

    update checklist components

commit 4367960fe4
Author: f0x <f0x@cthu.lu>
Date:   Mon Jan 30 23:46:20 2023 +0100

    checklist performance improvements

commit 204a4c02d1
Author: f0x <f0x@cthu.lu>
Date:   Mon Jan 30 20:05:34 2023 +0100

    checklist field: use reducer for state

* remove debug logging

* show and use domain block suggestion

* restructure import/export buttons

* updating suggestions

* suggestion overview

* restructure check-list behavior, domain import/export
This commit is contained in:
f0x52 2023-02-03 12:07:40 +01:00 committed by GitHub
parent 49beb17a8f
commit a59dc855d9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 1077 additions and 484 deletions

View file

@ -315,7 +315,7 @@ input, select, textarea, .input {
border-color: $input-focus-border;
}
&:invalid {
&:invalid, .invalid & {
border-color: $input-error-border;
}

View file

@ -66,10 +66,15 @@ skulk({
],
},
settings: {
debug: false,
entryFile: "settings",
outputFile: "settings.js",
prodCfg: prodCfg,
transform: [
["babelify", {
global: true,
ignore: [/node_modules\/(?!nanoid)/]
}]
],
presets: [
"react",
["postcss", {

View file

@ -19,9 +19,12 @@
"langs": "^2.0.0",
"match-sorter": "^6.3.1",
"modern-normalize": "^1.1.0",
"nanoid": "^4.0.0",
"papaparse": "^5.3.2",
"photoswipe": "^5.3.3",
"photoswipe-dynamic-caption-plugin": "^1.2.7",
"photoswipe-video-plugin": "^1.0.2",
"psl": "^1.9.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-error-boundary": "^3.1.4",

View file

@ -129,14 +129,16 @@ function CopyEmojiForm({ localEmojiCodes, type, emojiList }) {
title: "No emoji selected, cannot perform any actions"
};
const checkListExtraProps = React.useCallback(() => ({ localEmojiCodes }), [localEmojiCodes]);
return (
<div className="parsed">
<span>This {type == "statuses" ? "toot" : "account"} uses the following custom emoji, select the ones you want to copy/disable:</span>
<form onSubmit={formSubmit}>
<CheckList
field={form.selectedEmoji}
Component={EmojiEntry}
localEmojiCodes={localEmojiCodes}
EntryComponent={EmojiEntry}
getExtraProps={checkListExtraProps}
/>
<CategorySelect
@ -170,7 +172,7 @@ function ErrorList({ errors }) {
);
}
function EmojiEntry({ entry: emoji, localEmojiCodes, onChange }) {
function EmojiEntry({ entry: emoji, onChange, extraProps: { localEmojiCodes } }) {
const shortcodeField = useTextInput("shortcode", {
defaultValue: emoji.shortcode,
validator: function validateShortcode(code) {
@ -181,9 +183,16 @@ function EmojiEntry({ entry: emoji, localEmojiCodes, onChange }) {
});
React.useEffect(() => {
onChange({ valid: shortcodeField.valid });
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [shortcodeField.valid]);
if (emoji.valid != shortcodeField.valid) {
onChange({ valid: shortcodeField.valid });
}
}, [onChange, emoji.valid, shortcodeField.valid]);
React.useEffect(() => {
shortcodeField.validate();
// only need this update if it's the emoji.checked that updated, not shortcodeField
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [emoji.checked]);
return (
<>

View file

@ -1,307 +0,0 @@
/*
GoToSocial
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
"use strict";
const React = require("react");
const { Switch, Route, Redirect, useLocation } = require("wouter");
const query = require("../../lib/query");
const {
useTextInput,
useBoolInput,
useRadioInput,
useCheckListInput
} = require("../../lib/form");
const useFormSubmit = require("../../lib/form/submit");
const {
TextInput,
TextArea,
Checkbox,
Select,
RadioGroup
} = require("../../components/form/inputs");
const CheckList = require("../../components/check-list");
const MutationButton = require("../../components/form/mutation-button");
const isValidDomain = require("is-valid-domain");
const FormWithData = require("../../lib/form/form-with-data");
const { Error } = require("../../components/error");
const baseUrl = "/settings/admin/federation/import-export";
module.exports = function ImportExport() {
const [updateFromFile, setUpdateFromFile] = React.useState(false);
const form = {
domains: useTextInput("domains"),
exportType: useTextInput("exportType", { defaultValue: "plain", dontReset: true })
};
const [submitParse, parseResult] = useFormSubmit(form, query.useProcessDomainListMutation());
const [submitExport, exportResult] = useFormSubmit(form, query.useExportDomainListMutation());
function fileChanged(e) {
const reader = new FileReader();
reader.onload = function (read) {
form.domains.setter(read.target.result);
setUpdateFromFile(true);
};
reader.readAsText(e.target.files[0]);
}
React.useEffect(() => {
if (exportResult.isSuccess) {
form.domains.setter(exportResult.data);
}
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [exportResult]);
const [_location, setLocation] = useLocation();
if (updateFromFile) {
setUpdateFromFile(false);
submitParse();
}
return (
<Switch>
<Route path={`${baseUrl}/list`}>
{!parseResult.isSuccess && <Redirect to={baseUrl} />}
<h1>
<span className="button" onClick={() => {
parseResult.reset();
setLocation(baseUrl);
}}>
&lt; back
</span> Confirm import:
</h1>
<FormWithData
dataQuery={query.useInstanceBlocksQuery}
DataForm={ImportList}
list={parseResult.data}
/>
</Route>
<Route>
{parseResult.isSuccess && <Redirect to={`${baseUrl}/list`} />}
<h2>Import / Export suspended domains</h2>
<div>
<form onSubmit={submitParse}>
<TextArea
field={form.domains}
label="Domains, one per line (plaintext) or JSON"
placeholder={`google.com\nfacebook.com`}
rows={8}
/>
<div className="row">
<MutationButton label="Import" result={parseResult} showError={false} />
<button type="button" className="with-padding">
<label>
Import file
<input className="hidden" type="file" onChange={fileChanged} accept="application/json,text/plain" />
</label>
</button>
</div>
</form>
<form onSubmit={submitExport}>
<div className="row">
<MutationButton name="export" label="Export" result={exportResult} showError={false} />
<MutationButton name="export-file" label="Export file" result={exportResult} showError={false} />
<Select
field={form.exportType}
options={<>
<option value="plain">Text</option>
<option value="json">JSON</option>
</>}
/>
</div>
</form>
{parseResult.error && <Error error={parseResult.error} />}
{exportResult.error && <Error error={exportResult.error} />}
</div>
</Route>
</Switch>
);
};
function ImportList({ list, data: blockedInstances }) {
const hasComment = React.useMemo(() => {
let hasPublic = false;
let hasPrivate = false;
list.some((entry) => {
if (entry.public_comment?.length > 0) {
hasPublic = true;
}
if (entry.private_comment?.length > 0) {
hasPrivate = true;
}
return hasPublic && hasPrivate;
});
if (hasPublic && hasPrivate) {
return { both: true };
} else if (hasPublic) {
return { type: "public_comment" };
} else if (hasPrivate) {
return { type: "private_comment" };
} else {
return {};
}
}, [list]);
const showComment = useTextInput("showComment", { defaultValue: hasComment.type ?? "public_comment" });
let commentName = "";
if (showComment.value == "public_comment") { commentName = "Public comment"; }
if (showComment.value == "private_comment") { commentName = "Private comment"; }
const form = {
domains: useCheckListInput("domains", {
entries: list,
uniqueKey: "domain"
}),
obfuscate: useBoolInput("obfuscate"),
privateComment: useTextInput("private_comment", {
defaultValue: `Imported on ${new Date().toLocaleString()}`
}),
privateCommentBehavior: useRadioInput("private_comment_behavior", {
defaultValue: "append",
options: {
append: "Append to",
replace: "Replace"
}
}),
publicComment: useTextInput("public_comment"),
publicCommentBehavior: useRadioInput("public_comment_behavior", {
defaultValue: "append",
options: {
append: "Append to",
replace: "Replace"
}
}),
};
const [importDomains, importResult] = useFormSubmit(form, query.useImportDomainListMutation(), { changedOnly: false });
return (
<>
<form onSubmit={importDomains} className="suspend-import-list">
<span>{list.length} domain{list.length != 1 ? "s" : ""} in this list</span>
{hasComment.both &&
<Select field={showComment} options={
<>
<option value="public_comment">Show public comments</option>
<option value="private_comment">Show private comments</option>
</>
} />
}
<CheckList
field={form.domains}
Component={DomainEntry}
header={
<>
<b>Domain</b>
<b></b>
<b>{commentName}</b>
</>
}
blockedInstances={blockedInstances}
commentType={showComment.value}
/>
<TextArea
field={form.privateComment}
label="Private comment"
rows={3}
/>
<RadioGroup
field={form.privateCommentBehavior}
label="imported private comment"
/>
<TextArea
field={form.publicComment}
label="Public comment"
rows={3}
/>
<RadioGroup
field={form.publicCommentBehavior}
label="imported public comment"
/>
<Checkbox
field={form.obfuscate}
label="Obfuscate domains in public lists"
/>
<MutationButton label="Import" result={importResult} />
</form>
</>
);
}
function DomainEntry({ entry, onChange, blockedInstances, commentType }) {
const domainField = useTextInput("domain", {
defaultValue: entry.domain,
validator: (value) => {
return (entry.checked && !isValidDomain(value, { wildcard: true, allowUnicode: true }))
? "Invalid domain"
: "";
}
});
React.useEffect(() => {
onChange({ valid: domainField.valid });
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [domainField.valid]);
let icon = null;
if (blockedInstances[domainField.value] != undefined) {
icon = (
<>
<i className="fa fa-history already-blocked" aria-hidden="true" title="Domain block already exists"></i>
<span className="sr-only">Domain block already exists.</span>
</>
);
}
return (
<>
<TextInput
field={domainField}
onChange={(e) => {
domainField.onChange(e);
onChange({ domain: e.target.value, checked: true });
}}
/>
<span id="icon">{icon}</span>
<p>{entry[commentType]}</p>
</>
);
}

View file

@ -0,0 +1,64 @@
/*
GoToSocial
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
"use strict";
const React = require("react");
module.exports = function ExportFormatTable() {
return (
<table className="export-format-table">
<thead>
<tr>
<th rowSpan={2} />
<th colSpan={2}>Includes</th>
<th colSpan={2}>Importable by</th>
</tr>
<tr>
<th>Domain</th>
<th>Public comment</th>
<th>GoToSocial</th>
<th>Mastodon</th>
</tr>
</thead>
<tbody>
<Format name="Text" info={[true, false, true, false]} />
<Format name="JSON" info={[true, true, true, false]} />
<Format name="CSV" info={[true, true, true, true]} />
</tbody>
</table>
);
};
function Format({ name, info }) {
return (
<tr>
<td><b>{name}</b></td>
{info.map((b, key) => <td key={key} className="bool">{bool(b)}</td>)}
</tr>
);
}
function bool(val) {
return (
<>
<i className={`fa fa-${val ? "check" : "times"}`} aria-hidden="true"></i>
<span className="sr-only">{val ? "Yes" : "No"}</span>
</>
);
}

View file

@ -0,0 +1,123 @@
/*
GoToSocial
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
"use strict";
const React = require("react");
const query = require("../../../lib/query");
const useFormSubmit = require("../../../lib/form/submit");
const {
TextArea,
Select,
} = require("../../../components/form/inputs");
const MutationButton = require("../../../components/form/mutation-button");
const { Error } = require("../../../components/error");
const ExportFormatTable = require("./export-format-table");
module.exports = function ImportExportForm({ form, submitParse, parseResult }) {
const [submitExport, exportResult] = useFormSubmit(form, query.useExportDomainListMutation());
const [updateFromFile, setUpdateFromFile] = React.useState(false);
function fileChanged(e) {
const reader = new FileReader();
reader.onload = function (read) {
form.domains.setter(read.target.result);
setUpdateFromFile(true);
};
reader.readAsText(e.target.files[0]);
}
React.useEffect(() => {
if (exportResult.isSuccess) {
form.domains.setter(exportResult.data);
}
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [exportResult]);
if (updateFromFile) {
setUpdateFromFile(false);
submitParse();
}
return (
<>
<h1>Import / Export suspended domains</h1>
<p>
This page can be used to import and export lists of domains to suspend.
Exports can be done in various formats, with varying functionality and support in other software.
Imports will automatically detect what format is being processed.
</p>
<ExportFormatTable />
<div className="import-export">
<TextArea
field={form.domains}
label="Domains"
placeholder={`google.com\nfacebook.com`}
rows={8}
/>
<div className="button-grid">
<MutationButton
label="Import"
type="button"
onClick={() => submitParse()}
result={parseResult}
showError={false}
/>
<label className="button">
Import file
<input
type="file"
className="hidden"
onChange={fileChanged}
accept="application/json,text/plain,text/csv"
/>
</label>
<b /> {/* grid filler */}
<MutationButton
label="Export"
type="button"
onClick={() => submitExport("export")}
result={exportResult} showError={false}
/>
<MutationButton label="Export to file" type="button" onClick={() => submitExport("export-file")} result={exportResult} showError={false} />
<div className="export-file">
<span>
as
</span>
<Select
field={form.exportType}
options={<>
<option value="plain">Text</option>
<option value="json">JSON</option>
<option value="csv">CSV</option>
</>}
/>
</div>
</div>
{parseResult.error && <Error error={parseResult.error} />}
{exportResult.error && <Error error={exportResult.error} />}
</div>
</>
);
};

View file

@ -0,0 +1,78 @@
/*
GoToSocial
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
"use strict";
const React = require("react");
const { Switch, Route, Redirect, useLocation } = require("wouter");
const query = require("../../../lib/query");
const {
useTextInput,
} = require("../../../lib/form");
const useFormSubmit = require("../../../lib/form/submit");
const ProcessImport = require("./process");
const ImportExportForm = require("./form");
const baseUrl = "/settings/admin/federation/import-export";
module.exports = function ImportExport() {
const form = {
domains: useTextInput("domains"),
exportType: useTextInput("exportType", { defaultValue: "plain", dontReset: true })
};
const [submitParse, parseResult] = useFormSubmit(form, query.useProcessDomainListMutation());
const [_location, setLocation] = useLocation();
return (
<Switch>
<Route path={`${baseUrl}/process`}>
{parseResult.isSuccess ? (
<>
<h1>
<span className="button" onClick={() => {
parseResult.reset();
setLocation(baseUrl);
}}>
&lt; back
</span> Confirm import:
</h1>
<ProcessImport
list={parseResult.data}
/>
</>
) : <Redirect to={baseUrl} />}
</Route>
<Route>
{!parseResult.isSuccess ? (
<ImportExportForm
form={form}
submitParse={submitParse}
parseResult={parseResult}
/>
) : <Redirect to={`${baseUrl}/process`} />}
</Route>
</Switch>
);
};

View file

@ -0,0 +1,327 @@
/*
GoToSocial
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
"use strict";
const React = require("react");
const query = require("../../../lib/query");
const { isValidDomainBlock, hasBetterScope } = require("../../../lib/domain-block");
const {
useTextInput,
useBoolInput,
useRadioInput,
useCheckListInput
} = require("../../../lib/form");
const useFormSubmit = require("../../../lib/form/submit");
const {
TextInput,
TextArea,
Checkbox,
Select,
RadioGroup
} = require("../../../components/form/inputs");
const CheckList = require("../../../components/check-list");
const MutationButton = require("../../../components/form/mutation-button");
const FormWithData = require("../../../lib/form/form-with-data");
module.exports = React.memo(
function ProcessImport({ list }) {
return (
<div className="without-border">
<FormWithData
dataQuery={query.useInstanceBlocksQuery}
DataForm={ImportList}
list={list}
/>
</div>
);
}
);
function ImportList({ list, data: blockedInstances }) {
const hasComment = React.useMemo(() => {
let hasPublic = false;
let hasPrivate = false;
list.some((entry) => {
if (entry.public_comment?.length > 0) {
hasPublic = true;
}
if (entry.private_comment?.length > 0) {
hasPrivate = true;
}
return hasPublic && hasPrivate;
});
if (hasPublic && hasPrivate) {
return { both: true };
} else if (hasPublic) {
return { type: "public_comment" };
} else if (hasPrivate) {
return { type: "private_comment" };
} else {
return {};
}
}, [list]);
const showComment = useTextInput("showComment", { defaultValue: hasComment.type ?? "public_comment" });
const form = {
domains: useCheckListInput("domains", { entries: list }),
obfuscate: useBoolInput("obfuscate"),
privateComment: useTextInput("private_comment", {
defaultValue: `Imported on ${new Date().toLocaleString()}`
}),
privateCommentBehavior: useRadioInput("private_comment_behavior", {
defaultValue: "append",
options: {
append: "Append to",
replace: "Replace"
}
}),
publicComment: useTextInput("public_comment"),
publicCommentBehavior: useRadioInput("public_comment_behavior", {
defaultValue: "append",
options: {
append: "Append to",
replace: "Replace"
}
}),
};
const [importDomains, importResult] = useFormSubmit(form, query.useImportDomainListMutation(), { changedOnly: false });
return (
<>
<form onSubmit={importDomains} className="suspend-import-list">
<span>{list.length} domain{list.length != 1 ? "s" : ""} in this list</span>
{hasComment.both &&
<Select field={showComment} options={
<>
<option value="public_comment">Show public comments</option>
<option value="private_comment">Show private comments</option>
</>
} />
}
<DomainCheckList
field={form.domains}
blockedInstances={blockedInstances}
commentType={showComment.value}
/>
<TextArea
field={form.privateComment}
label="Private comment"
rows={3}
/>
<RadioGroup
field={form.privateCommentBehavior}
label="imported private comment"
/>
<TextArea
field={form.publicComment}
label="Public comment"
rows={3}
/>
<RadioGroup
field={form.publicCommentBehavior}
label="imported public comment"
/>
<Checkbox
field={form.obfuscate}
label="Obfuscate domains in public lists"
/>
<MutationButton label="Import" result={importResult} />
</form>
</>
);
}
function DomainCheckList({ field, blockedInstances, commentType }) {
const getExtraProps = React.useCallback((entry) => {
return {
comment: entry[commentType],
alreadyExists: blockedInstances[entry.domain] != undefined
};
}, [blockedInstances, commentType]);
const entriesWithSuggestions = React.useMemo(() => (
Object.values(field.value).filter((entry) => entry.suggest)
), [field.value]);
return (
<>
<CheckList
field={field}
header={<>
<b>Domain</b>
<b></b>
<b>
{commentType == "public_comment" && "Public comment"}
{commentType == "private_comment" && "Private comment"}
</b>
</>}
EntryComponent={DomainEntry}
getExtraProps={getExtraProps}
/>
<UpdateHint
entries={entriesWithSuggestions}
updateEntry={field.onChange}
updateMultiple={field.updateMultiple}
/>
</>
);
}
const UpdateHint = React.memo(
function UpdateHint({ entries, updateEntry, updateMultiple }) {
if (entries.length == 0) {
return null;
}
function changeAll() {
updateMultiple(
entries.map((entry) => [entry.key, { domain: entry.suggest, suggest: null }])
);
}
return (
<div className="update-hints">
<p>
{entries.length} {entries.length == 1 ? "entry uses" : "entries use"} a specific subdomain,
which you might want to change to the main domain, as that includes all it's (future) subdomains.
</p>
<div className="hints">
{entries.map((entry) => (
<UpdateableEntry key={entry.key} entry={entry} updateEntry={updateEntry} />
))}
</div>
{entries.length > 0 && <a onClick={changeAll}>change all</a>}
</div>
);
}
);
const UpdateableEntry = React.memo(
function UpdateableEntry({ entry, updateEntry }) {
return (
<>
<span className="text-cutoff">{entry.domain}</span>
<i class="fa fa-long-arrow-right" aria-hidden="true"></i>
<span>{entry.suggest}</span>
<a role="button" onClick={() =>
updateEntry(entry.key, { domain: entry.suggest, suggest: null })
}>change</a>
</>
);
}
);
function domainValidationError(isValid) {
return isValid ? "" : "Invalid domain";
}
function DomainEntry({ entry, onChange, extraProps: { alreadyExists, comment } }) {
const domainField = useTextInput("domain", {
defaultValue: entry.domain,
showValidation: entry.checked,
initValidation: domainValidationError(entry.valid),
validator: (value) => domainValidationError(isValidDomainBlock(value))
});
React.useEffect(() => {
if (entry.valid != domainField.valid) {
onChange({ valid: domainField.valid });
}
}, [onChange, entry.valid, domainField.valid]);
React.useEffect(() => {
if (entry.domain != domainField.value) {
domainField.setter(entry.domain);
}
// domainField.setter is enough, eslint wants domainField
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [entry.domain, domainField.setter]);
React.useEffect(() => {
onChange({ suggest: hasBetterScope(domainField.value) });
// only need this update if it's the entry.checked that updated, not onChange
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [domainField.value]);
function clickIcon(e) {
if (entry.suggest) {
e.stopPropagation();
e.preventDefault();
domainField.setter(entry.suggest);
onChange({ domain: entry.suggest, checked: true });
}
}
return (
<>
<TextInput
field={domainField}
onChange={(e) => {
domainField.onChange(e);
onChange({ domain: e.target.value, checked: true });
}}
/>
<span id="icon" onClick={clickIcon}>
<DomainEntryIcon alreadyExists={alreadyExists} suggestion={entry.suggest} onChange={onChange} />
</span>
<p>{comment}</p>
</>
);
}
function DomainEntryIcon({ alreadyExists, suggestion }) {
let icon;
let text;
if (suggestion) {
icon = "fa-info-circle suggest-changes";
text = `Entry targets a specific subdomain, consider changing it to '${suggestion}'.`;
} else if (alreadyExists) {
icon = "fa-history already-blocked";
text = "Domain block already exists.";
}
if (!icon) {
return null;
}
return (
<>
<i className={`fa ${icon}`} aria-hidden="true" title={text}></i>
<span className="sr-only">{text}</span>
</>
);
}

View file

@ -20,39 +20,71 @@
const React = require("react");
module.exports = function CheckList({ field, Component, header = " All", ...componentProps }) {
module.exports = function CheckList({ field, header = "All", EntryComponent, getExtraProps }) {
return (
<div className="checkbox-list list">
<label className="header">
<input
ref={field.toggleAll.ref}
type="checkbox"
onChange={field.toggleAll.onChange}
checked={field.toggleAll.value === 1}
/> {header}
</label>
{Object.values(field.value).map((entry) => (
<CheckListEntry
key={entry.key}
onChange={(value) => field.onChange(entry.key, value)}
entry={entry}
Component={Component}
componentProps={componentProps}
/>
))}
<CheckListHeader toggleAll={field.toggleAll}> {header}</CheckListHeader>
<CheckListEntries
entries={field.value}
updateValue={field.onChange}
EntryComponent={EntryComponent}
getExtraProps={getExtraProps}
/>
</div>
);
};
function CheckListEntry({ entry, onChange, Component, componentProps }) {
function CheckListHeader({ toggleAll, children }) {
return (
<label className="entry">
<label className="header entry">
<input
ref={toggleAll.ref}
type="checkbox"
onChange={(e) => onChange({ checked: e.target.checked })}
checked={entry.checked}
/>
<Component entry={entry} onChange={onChange} {...componentProps} />
onChange={toggleAll.onChange}
/> {children}
</label>
);
}
const CheckListEntries = React.memo(
function CheckListEntries({ entries, updateValue, EntryComponent, getExtraProps }) {
const deferredEntries = React.useDeferredValue(entries);
return Object.values(deferredEntries).map((entry) => (
<CheckListEntry
key={entry.key}
entry={entry}
updateValue={updateValue}
EntryComponent={EntryComponent}
getExtraProps={getExtraProps}
/>
));
}
);
/*
React.memo is a performance optimization that only re-renders a CheckListEntry
when it's props actually change, instead of every time anything
in the list (CheckListEntries) updates
*/
const CheckListEntry = React.memo(
function CheckListEntry({ entry, updateValue, getExtraProps, EntryComponent }) {
const onChange = React.useCallback(
(value) => updateValue(entry.key, value),
[updateValue, entry.key]
);
const extraProps = React.useMemo(() => getExtraProps?.(entry), [getExtraProps, entry]);
return (
<label className="entry">
<input
type="checkbox"
onChange={(e) => onChange({ checked: e.target.checked })}
checked={entry.checked}
/>
<EntryComponent entry={entry} onChange={onChange} extraProps={extraProps} />
</label>
);
}
);

View file

@ -22,9 +22,10 @@ const React = require("react");
function TextInput({ label, field, ...inputProps }) {
const { onChange, value, ref } = field;
console.log(field.name, field.valid, field.value);
return (
<div className="form-field text">
<div className={`form-field text${field.valid ? "" : " invalid"}`}>
<label>
{label}
<input

View file

@ -0,0 +1,50 @@
/*
GoToSocial
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
"use strict";
const isValidDomain = require("is-valid-domain");
const psl = require("psl");
function isValidDomainBlock(domain) {
return isValidDomain(domain, {
/*
Wildcard prefix *. can be stripped since it's equivalent to not having it,
but wildcard anywhere else in the domain is not handled by the backend so it's invalid.
*/
wildcard: false,
allowUnicode: true
});
}
/*
Still can't think of a better function name for this,
but we're checking a domain against the Public Suffix List <https://publicsuffix.org/>
to see if we should suggest removing subdomain(s) since they're likely owned/ran by the same party
social.example.com -> suggests example.com
*/
function hasBetterScope(domain) {
const lookup = psl.get(domain);
if (lookup && lookup != domain) {
return lookup;
} else {
return false;
}
}
module.exports = { isValidDomainBlock, hasBetterScope };

View file

@ -20,128 +20,163 @@
const React = require("react");
const syncpipe = require("syncpipe");
const { createSlice } = require("@reduxjs/toolkit");
const { enableMapSet } = require("immer");
function createState(entries, uniqueKey, oldState, defaultValue) {
return syncpipe(entries, [
(_) => _.map((entry) => {
let key = entry[uniqueKey];
return [
key,
{
...entry,
key,
checked: oldState[key]?.checked ?? entry.checked ?? defaultValue
enableMapSet(); // for use in reducers
const { reducer, actions } = createSlice({
name: "checklist",
initialState: {}, // not handled by slice itself
reducers: {
updateAll: (state, { payload: checked }) => {
const selectedEntries = new Set();
return {
entries: syncpipe(state.entries, [
(_) => Object.values(_),
(_) => _.map((entry) => {
if (checked) {
selectedEntries.add(entry.key);
}
return [entry.key, {
...entry,
checked
}];
}),
(_) => Object.fromEntries(_)
]),
selectedEntries
};
},
update: (state, { payload: { key, value } }) => {
if (value.checked !== undefined) {
if (value.checked === true) {
state.selectedEntries.add(key);
} else {
state.selectedEntries.delete(key);
}
];
}),
(_) => Object.fromEntries(_)
]);
}
}
function updateAllState(state, newValue) {
return syncpipe(state, [
(_) => Object.values(_),
(_) => _.map((entry) => [entry.key, {
...entry,
checked: newValue
}]),
(_) => Object.fromEntries(_)
]);
}
state.entries[key] = {
...state.entries[key],
...value
};
},
updateMultiple: (state, { payload }) => {
payload.forEach(([key, value]) => {
if (value.checked !== undefined) {
if (value.checked === true) {
state.selectedEntries.add(key);
} else {
state.selectedEntries.delete(key);
}
}
function updateState(state, key, newValue) {
return {
...state,
[key]: {
...state[key],
...newValue
state.entries[key] = {
...state.entries[key],
...value
};
});
}
}
});
function initialState({ entries, uniqueKey, defaultValue }) {
const selectedEntries = new Set();
return {
entries: syncpipe(entries, [
(_) => _.map((entry) => {
let key = entry[uniqueKey];
let checked = entry.checked ?? defaultValue;
if (checked) {
selectedEntries.add(key);
} else {
selectedEntries.delete(key);
}
return [
key,
{
...entry,
key,
checked
}
];
}),
(_) => Object.fromEntries(_)
]),
selectedEntries
};
}
module.exports = function useCheckListInput({ name }, { entries, uniqueKey = "key", defaultValue = false }) {
const [state, setState] = React.useState({});
const [state, dispatch] = React.useReducer(reducer, null,
() => initialState({ entries, uniqueKey, defaultValue }) // initial state
);
const [someSelected, setSomeSelected] = React.useState(false);
const [toggleAllState, setToggleAllState] = React.useState(0);
const toggleAllRef = React.useRef(null);
React.useEffect(() => {
/*
entries changed, update state,
re-using old state if available for key
*/
setState(createState(entries, uniqueKey, state, defaultValue));
if (toggleAllRef.current != null) {
let some = state.selectedEntries.size > 0;
let all = false;
if (some) {
all = state.selectedEntries.size == Object.values(state.entries).length;
}
toggleAllRef.current.checked = all;
toggleAllRef.current.indeterminate = some && !all;
}
// only needs to update when state.selectedEntries changes, not state.entries
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.selectedEntries]);
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [entries]);
const reset = React.useCallback(
() => dispatch(actions.updateAll(defaultValue)),
[defaultValue]
);
React.useEffect(() => {
/* Updates (un)check all checkbox, based on shortcode checkboxes
Can be 0 (not checked), 1 (checked) or 2 (indeterminate)
*/
if (toggleAllRef.current == null) {
return;
const onChange = React.useCallback(
(key, value) => dispatch(actions.update({ key, value })),
[]
);
const updateMultiple = React.useCallback(
(entries) => dispatch(actions.updateMultiple(entries)),
[]
);
return React.useMemo(() => {
function toggleAll(e) {
let checked = e.target.checked;
if (e.target.indeterminate) {
checked = false;
}
dispatch(actions.updateAll(checked));
}
let values = Object.values(state);
/* one or more boxes are checked */
let some = values.some((v) => v.checked);
let all = false;
if (some) {
/* there's not at least one unchecked box */
all = !values.some((v) => v.checked == false);
function selectedValues() {
return Array.from((state.selectedEntries)).map((key) => ({
...state.entries[key] // returned as new object, because reducer state is immutable
}));
}
setSomeSelected(some);
if (some && !all) {
setToggleAllState(2);
toggleAllRef.current.indeterminate = true;
} else {
setToggleAllState(all ? 1 : 0);
toggleAllRef.current.indeterminate = false;
}
}, [state, toggleAllRef]);
function toggleAll(e) {
let selectAll = e.target.checked;
if (toggleAllState == 2) { // indeterminate
selectAll = false;
}
setState(updateAllState(state, selectAll));
setToggleAllState(selectAll);
}
function reset() {
setState(updateAllState(state, defaultValue));
}
function selectedValues() {
return syncpipe(state, [
(_) => Object.values(_),
(_) => _.filter((entry) => entry.checked)
]);
}
return Object.assign([
state,
reset,
{ name }
], {
name,
value: state,
onChange: (key, newValue) => setState(updateState(state, key, newValue)),
selectedValues,
reset,
someSelected,
toggleAll: {
ref: toggleAllRef,
value: toggleAllState,
onChange: toggleAll
}
});
return Object.assign([
state,
reset,
{ name }
], {
name,
value: state.entries,
onChange,
selectedValues,
reset,
someSelected: state.someChecked,
updateMultiple,
toggleAll: {
ref: toggleAllRef,
onChange: toggleAll
}
});
}, [state, reset, name, onChange, updateMultiple]);
};

View file

@ -20,14 +20,30 @@
const React = require("react");
module.exports = function useTextInput({ name, Name }, { validator, defaultValue = "", dontReset = false } = {}) {
module.exports = function useTextInput({ name, Name }, {
defaultValue = "",
dontReset = false,
validator,
showValidation = true,
initValidation
} = {}) {
const [text, setText] = React.useState(defaultValue);
const [valid, setValid] = React.useState(true);
const textRef = React.useRef(null);
const [validation, setValidation] = React.useState(initValidation ?? "");
const [_isValidating, startValidation] = React.useTransition();
let valid = validation == "";
function onChange(e) {
let input = e.target.value;
setText(input);
if (validator) {
startValidation(() => {
setValidation(validator(input));
});
}
}
function reset() {
@ -38,11 +54,13 @@ module.exports = function useTextInput({ name, Name }, { validator, defaultValue
React.useEffect(() => {
if (validator && textRef.current) {
let res = validator(text);
setValid(res == "");
textRef.current.setCustomValidity(res);
if (showValidation) {
textRef.current.setCustomValidity(validation);
} else {
textRef.current.setCustomValidity("");
}
}
}, [text, textRef, validator]);
}, [validation, validator, showValidation]);
// Array / Object hybrid, for easier access in different contexts
return Object.assign([
@ -62,6 +80,7 @@ module.exports = function useTextInput({ name, Name }, { validator, defaultValue
ref: textRef,
setter: setText,
valid,
validate: () => setValidation(validator(text)),
hasChanged: () => text != defaultValue
});
};

View file

@ -19,8 +19,11 @@
"use strict";
const Promise = require("bluebird");
const isValidDomain = require("is-valid-domain");
const fileDownload = require("js-file-download");
const csv = require("papaparse");
const { nanoid } = require("nanoid");
const { isValidDomainBlock, hasBetterScope } = require("../../domain-block");
const {
replaceCacheOnMutation,
@ -31,6 +34,23 @@ const {
function parseDomainList(list) {
if (list[0] == "[") {
return JSON.parse(list);
} else if (list.startsWith("#domain")) { // Mastodon CSV
const { data, errors } = csv.parse(list, {
header: true,
transformHeader: (header) => header.slice(1), // removes starting '#'
skipEmptyLines: true,
dynamicTyping: true
});
if (errors.length > 0) {
let error = "";
errors.forEach((err) => {
error += `${err.message} (line ${err.row})`;
});
throw error;
}
return data;
} else {
return list.split("\n").map((line) => {
let domain = line.trim();
@ -51,7 +71,15 @@ function parseDomainList(list) {
function validateDomainList(list) {
list.forEach((entry) => {
entry.valid = (entry.valid !== false) && isValidDomain(entry.domain, { wildcard: true, allowUnicode: true });
if (entry.domain.startsWith("*.")) {
// domain block always includes all subdomains, wildcard is meaningless here
entry.domain = entry.domain.slice(2);
}
entry.valid = (entry.valid !== false) && isValidDomainBlock(entry.domain);
if (entry.valid) {
entry.suggest = hasBetterScope(entry.domain);
}
entry.checked = entry.valid;
});
@ -83,6 +111,9 @@ module.exports = (build) => ({
}).then((deduped) => {
return validateDomainList(deduped);
}).then((data) => {
data.forEach((entry) => {
entry.key = nanoid(); // unique id that stays stable even if domain gets modified by user
});
return { data };
}).catch((e) => {
return { error: e.toString() };
@ -91,27 +122,53 @@ module.exports = (build) => ({
}),
exportDomainList: build.mutation({
queryFn: (formData, api, _extraOpts, baseQuery) => {
let process;
if (formData.exportType == "json") {
process = {
transformEntry: (entry) => ({
domain: entry.domain,
public_comment: entry.public_comment,
obfuscate: entry.obfuscate
}),
stringify: (list) => JSON.stringify(list),
extension: ".json",
mime: "application/json"
};
} else if (formData.exportType == "csv") {
process = {
transformEntry: (entry) => [
entry.domain,
"suspend", // severity
false, // reject_media
false, // reject_reports
entry.public_comment,
entry.obfuscate ?? false
],
stringify: (list) => csv.unparse({
fields: "#domain,#severity,#reject_media,#reject_reports,#public_comment,#obfuscate".split(","),
data: list
}),
extension: ".csv",
mime: "text/csv"
};
} else {
process = {
transformEntry: (entry) => entry.domain,
stringify: (list) => list.join("\n"),
extension: ".txt",
mime: "text/plain"
};
}
return Promise.try(() => {
return baseQuery({
url: `/api/v1/admin/domain_blocks`
});
}).then(unwrapRes).then((blockedInstances) => {
return blockedInstances.map((entry) => {
if (formData.exportType == "json") {
return {
domain: entry.domain,
public_comment: entry.public_comment
};
} else {
return entry.domain;
}
});
return blockedInstances.map(process.transformEntry);
}).then((exportList) => {
if (formData.exportType == "json") {
return JSON.stringify(exportList);
} else {
return exportList.join("\n");
}
return process.stringify(exportList);
}).then((exportAsString) => {
if (formData.action == "export") {
return {
@ -120,7 +177,6 @@ module.exports = (build) => ({
} else if (formData.action == "export-file") {
let domain = new URL(api.getState().oauth.instance).host;
let date = new Date();
let mime;
let filename = [
domain,
@ -130,15 +186,11 @@ module.exports = (build) => ({
date.getDate().toString().padStart(2, "0"),
].join("-");
if (formData.exportType == "json") {
filename += ".json";
mime = "application/json";
} else {
filename += ".txt";
mime = "text/plain";
}
fileDownload(exportAsString, filename, mime);
fileDownload(
exportAsString,
filename + process.extension,
process.mime
);
}
return { data: null };
}).catch((e) => {
@ -171,6 +223,7 @@ module.exports = (build) => ({
})
});
const internalKeys = new Set("key,suggest,valid,checked".split(","));
function entryProcessor(formData) {
let funcs = [];
@ -204,7 +257,7 @@ function entryProcessor(formData) {
entry.obfuscate = formData.obfuscate;
Object.entries(entry).forEach(([key, val]) => {
if (val == undefined) {
if (internalKeys.has(key) || val == undefined) {
delete entry[key];
}
});

View file

@ -69,6 +69,10 @@ section {
&:last-child {
margin-bottom: 0;
}
&.without-border {
border-left: 0;
}
}
}
@ -370,7 +374,8 @@ span.form-info {
.checkbox-list {
.header, .entry {
gap: 1rem;
display: grid;
gap: 0 1rem;
}
}
@ -629,7 +634,6 @@ span.form-info {
.checkbox-list {
.entry {
display: grid;
grid-template-columns: auto auto 1fr;
}
@ -688,9 +692,14 @@ button.with-padding {
.suspend-import-list {
.checkbox-list {
.header, .entry {
display: grid;
.entry {
grid-template-columns: auto 25ch auto 1fr;
grid-template-rows: auto 1fr;
p {
grid-column: 4;
grid-row: 1 / span 2;
}
}
}
@ -704,6 +713,10 @@ button.with-padding {
color: $green1;
}
#icon .suggest-changes {
color: $orange2;
}
p {
align-self: center;
margin: 0;
@ -711,6 +724,75 @@ button.with-padding {
}
}
.import-export {
p {
margin: 0;
}
.export-file {
display: flex;
gap: 0.7rem;
align-items: center;
}
.button-grid {
display: inline-grid;
grid-template-columns: auto auto auto;
align-self: start;
gap: 0.5rem;
button {
width: 100%;
}
}
}
.update-hints {
background: $list-entry-alternate-bg;
border: 0.1rem solid $border-accent;
/* border-radius: $br; */
padding: 0.5rem;
display: flex;
flex-direction: column;
.hints {
max-width: 100%;
align-self: start;
align-items: center;
margin: 1rem 0;
display: inline-grid;
grid-template-columns: auto auto auto auto;
gap: 1rem;
}
}
.export-format-table {
width: 100%;
background: $list-entry-alternate-bg;
border-collapse: collapse;
th, td {
border: 0.1rem solid $gray1;
padding: 0.3rem;
}
th {
background: $list-entry-bg;
}
td {
text-align: center;
.fa-check {
color: $green1;
}
.fa-times {
color: $error3;
}
}
}
.form-field.radio {
&, label {
display: flex;
@ -723,6 +805,10 @@ button.with-padding {
}
}
[role="button"] {
cursor: pointer;
}
@keyframes fadeout {
from {
opacity: 1;

View file

@ -3953,6 +3953,11 @@ nanoid@^3.3.4:
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab"
integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==
nanoid@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-4.0.0.tgz#6e144dee117609232c3f415c34b0e550e64999a5"
integrity sha512-IgBP8piMxe/gf73RTQx7hmnhwz0aaEXYakvqZyE302IXW3HyVNhdNGC+O2MwMAVhLEnvXlvKtGbtJf6wvHihCg==
natural-compare@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
@ -4117,6 +4122,11 @@ pako@~1.0.5:
resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf"
integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==
papaparse@^5.3.2:
version "5.3.2"
resolved "https://registry.yarnpkg.com/papaparse/-/papaparse-5.3.2.tgz#d1abed498a0ee299f103130a6109720404fbd467"
integrity sha512-6dNZu0Ki+gyV0eBsFKJhYr+MdQYAzFUGlBMNj3GNrmHxmz1lfRa24CjFObPXtjcetlOv5Ad299MhIK0znp3afw==
parent-module@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2"
@ -4348,6 +4358,11 @@ proxy-addr@~2.0.7:
forwarded "0.2.0"
ipaddr.js "1.9.1"
psl@^1.9.0:
version "1.9.0"
resolved "https://registry.yarnpkg.com/psl/-/psl-1.9.0.tgz#d0df2a137f00794565fcaf3b2c00cd09f8d5a5a7"
integrity sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==
public-encrypt@^4.0.0:
version "4.0.3"
resolved "https://registry.yarnpkg.com/public-encrypt/-/public-encrypt-4.0.3.tgz#4fcc9d77a07e48ba7527e7cbe0de33d0701331e0"