diff options
Diffstat (limited to 'web/source/settings')
69 files changed, 3696 insertions, 1671 deletions
diff --git a/web/source/settings/admin/accounts/detail.jsx b/web/source/settings/admin/accounts/detail.jsx index 0e906cd1c..63049c149 100644 --- a/web/source/settings/admin/accounts/detail.jsx +++ b/web/source/settings/admin/accounts/detail.jsx @@ -22,13 +22,13 @@ const { useRoute, Redirect } = require("wouter"); const query = require("../../lib/query"); -const FormWithData = require("../../lib/form/form-with-data"); +const FormWithData = require("../../lib/form/form-with-data").default; const { useBaseUrl } = require("../../lib/navigation/util"); const FakeProfile = require("../../components/fake-profile"); const MutationButton = require("../../components/form/mutation-button"); -const useFormSubmit = require("../../lib/form/submit"); +const useFormSubmit = require("../../lib/form/submit").default; const { useValue, useTextInput } = require("../../lib/form"); const { TextInput } = require("../../components/form/inputs"); @@ -77,7 +77,7 @@ function AccountDetailForm({ data: account }) { function ModifyAccount({ account }) { const form = { id: useValue("id", account.id), - reason: useTextInput("text", {}) + reason: useTextInput("text") }; const [modifyAccount, result] = useFormSubmit(form, query.useActionAccountMutation()); diff --git a/web/source/settings/admin/domain-permissions/detail.tsx b/web/source/settings/admin/domain-permissions/detail.tsx new file mode 100644 index 000000000..f74802666 --- /dev/null +++ b/web/source/settings/admin/domain-permissions/detail.tsx @@ -0,0 +1,254 @@ +/* + GoToSocial + Copyright (C) GoToSocial Authors admin@gotosocial.org + SPDX-License-Identifier: AGPL-3.0-or-later + + 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/>. +*/ + +import React from "react"; + +import { useMemo } from "react"; +import { useLocation } from "wouter"; + +import { useTextInput, useBoolInput } from "../../lib/form"; + +import useFormSubmit from "../../lib/form/submit"; + +import { TextInput, Checkbox, TextArea } from "../../components/form/inputs"; + +import Loading from "../../components/loading"; +import BackButton from "../../components/back-button"; +import MutationButton from "../../components/form/mutation-button"; + +import { useDomainAllowsQuery, useDomainBlocksQuery } from "../../lib/query/admin/domain-permissions/get"; +import { useAddDomainAllowMutation, useAddDomainBlockMutation, useRemoveDomainAllowMutation, useRemoveDomainBlockMutation } from "../../lib/query/admin/domain-permissions/update"; +import { DomainPerm, PermType } from "../../lib/types/domain-permission"; +import { NoArg } from "../../lib/types/query"; +import { Error } from "../../components/error"; + +export interface DomainPermDetailProps { + baseUrl: string; + permType: PermType; + domain: string; +} + +export default function DomainPermDetail({ baseUrl, permType, domain }: DomainPermDetailProps) { + const { data: domainBlocks = {}, isLoading: isLoadingDomainBlocks } = useDomainBlocksQuery(NoArg, { skip: permType !== "block" }); + const { data: domainAllows = {}, isLoading: isLoadingDomainAllows } = useDomainAllowsQuery(NoArg, { skip: permType !== "allow" }); + + let isLoading; + switch (permType) { + case "block": + isLoading = isLoadingDomainBlocks; + break; + case "allow": + isLoading = isLoadingDomainAllows; + break; + default: + throw "perm type unknown"; + } + + if (domain == "view") { + // Retrieve domain from form field submission. + domain = (new URL(document.location.toString())).searchParams.get("domain")?? "unknown"; + } + + if (domain == "unknown") { + throw "unknown domain"; + } + + // Normalize / decode domain (it may be URL-encoded). + domain = decodeURIComponent(domain); + + // Check if we already have a perm of the desired type for this domain. + const existingPerm: DomainPerm | undefined = useMemo(() => { + if (permType == "block") { + return domainBlocks[domain]; + } else { + return domainAllows[domain]; + } + }, [domainBlocks, domainAllows, domain, permType]); + + let infoContent: React.JSX.Element; + + if (isLoading) { + infoContent = <Loading />; + } else if (existingPerm == undefined) { + infoContent = <span>No stored {permType} yet, you can add one below:</span>; + } else { + infoContent = ( + <div className="info"> + <i className="fa fa-fw fa-exclamation-triangle" aria-hidden="true"></i> + <b>Editing domain permissions isn't implemented yet, <a href="https://github.com/superseriousbusiness/gotosocial/issues/1198" target="_blank" rel="noopener noreferrer">check here for progress</a></b> + </div> + ); + } + + return ( + <div> + <h1 className="text-cutoff"><BackButton to={baseUrl} /> Domain {permType} for: <span title={domain}>{domain}</span></h1> + {infoContent} + <DomainPermForm + defaultDomain={domain} + perm={existingPerm} + permType={permType} + baseUrl={baseUrl} + /> + </div> + ); +} + +interface DomainPermFormProps { + defaultDomain: string; + perm?: DomainPerm; + permType: PermType; + baseUrl: string; +} + +function DomainPermForm({ defaultDomain, perm, permType, baseUrl }: DomainPermFormProps) { + const isExistingPerm = perm !== undefined; + const disabledForm = isExistingPerm + ? { + disabled: true, + title: "Domain permissions currently cannot be edited." + } + : { + disabled: false, + title: "", + }; + + const form = { + domain: useTextInput("domain", { source: perm, defaultValue: defaultDomain }), + obfuscate: useBoolInput("obfuscate", { source: perm }), + commentPrivate: useTextInput("private_comment", { source: perm }), + commentPublic: useTextInput("public_comment", { source: perm }) + }; + + // Check which perm type we're meant to be handling + // here, and use appropriate mutations and results. + // We can't call these hooks conditionally because + // react is like "weh" (mood), but we can decide + // which ones to use conditionally. + const [ addBlock, addBlockResult ] = useAddDomainBlockMutation(); + const [ removeBlock, removeBlockResult] = useRemoveDomainBlockMutation({ fixedCacheKey: perm?.id }); + const [ addAllow, addAllowResult ] = useAddDomainAllowMutation(); + const [ removeAllow, removeAllowResult ] = useRemoveDomainAllowMutation({ fixedCacheKey: perm?.id }); + + const [ + addTrigger, + addResult, + removeTrigger, + removeResult, + ] = useMemo(() => { + return permType == "block" + ? [ + addBlock, + addBlockResult, + removeBlock, + removeBlockResult, + ] + : [ + addAllow, + addAllowResult, + removeAllow, + removeAllowResult, + ]; + }, [permType, + addBlock, addBlockResult, removeBlock, removeBlockResult, + addAllow, addAllowResult, removeAllow, removeAllowResult, + ]); + + // Use appropriate submission params for this permType. + const [submitForm, submitFormResult] = useFormSubmit(form, [addTrigger, addResult], { changedOnly: false }); + + // Uppercase first letter of given permType. + const permTypeUpper = useMemo(() => { + return permType.charAt(0).toUpperCase() + permType.slice(1); + }, [permType]); + + const [location, setLocation] = useLocation(); + + function verifyUrlThenSubmit(e) { + // Adding a new domain permissions happens on a url like + // "/settings/admin/domain-permissions/:permType/domain.com", + // but if domain input changes, that doesn't match anymore + // and causes issues later on so, before submitting the form, + // silently change url, and THEN submit. + let correctUrl = `${baseUrl}/${form.domain.value}`; + if (location != correctUrl) { + setLocation(correctUrl); + } + return submitForm(e); + } + + return ( + <form onSubmit={verifyUrlThenSubmit}> + <TextInput + field={form.domain} + label="Domain" + placeholder="example.com" + {...disabledForm} + /> + + <Checkbox + field={form.obfuscate} + label="Obfuscate domain in public lists" + {...disabledForm} + /> + + <TextArea + field={form.commentPrivate} + label="Private comment" + rows={3} + {...disabledForm} + /> + + <TextArea + field={form.commentPublic} + label="Public comment" + rows={3} + {...disabledForm} + /> + + <div className="action-buttons row"> + <MutationButton + label={permTypeUpper} + result={submitFormResult} + showError={false} + {...disabledForm} + /> + + { + isExistingPerm && + <MutationButton + type="button" + onClick={() => removeTrigger(perm.id?? "")} + label="Remove" + result={removeResult} + className="button danger" + showError={false} + disabled={!isExistingPerm} + /> + } + </div> + + <> + {addResult.error && <Error error={addResult.error} />} + {removeResult.error && <Error error={removeResult.error} />} + </> + + </form> + ); +} diff --git a/web/source/settings/admin/federation/import-export/export-format-table.jsx b/web/source/settings/admin/domain-permissions/export-format-table.jsx index 7fcffa348..7fcffa348 100644 --- a/web/source/settings/admin/federation/import-export/export-format-table.jsx +++ b/web/source/settings/admin/domain-permissions/export-format-table.jsx diff --git a/web/source/settings/admin/federation/import-export/form.jsx b/web/source/settings/admin/domain-permissions/form.tsx index 2086739e3..fb639202d 100644 --- a/web/source/settings/admin/federation/import-export/form.jsx +++ b/web/source/settings/admin/domain-permissions/form.tsx @@ -17,34 +17,57 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. */ -const React = require("react"); +import React from "react"; -const query = require("../../../lib/query"); -const useFormSubmit = require("../../../lib/form/submit"); +import { useEffect } from "react"; -const { +import { useExportDomainListMutation } from "../../lib/query/admin/domain-permissions/export"; +import useFormSubmit from "../../lib/form/submit"; + +import { + RadioGroup, TextArea, Select, -} = require("../../../components/form/inputs"); +} from "../../components/form/inputs"; + +import MutationButton from "../../components/form/mutation-button"; + +import { Error } from "../../components/error"; +import ExportFormatTable from "./export-format-table"; -const MutationButton = require("../../../components/form/mutation-button"); +import type { + FormSubmitFunction, + FormSubmitResult, + RadioFormInputHook, + TextFormInputHook, +} from "../../lib/form/types"; -const { Error } = require("../../../components/error"); -const ExportFormatTable = require("./export-format-table"); +export interface ImportExportFormProps { + form: { + domains: TextFormInputHook; + exportType: TextFormInputHook; + permType: RadioFormInputHook; + }; + submitParse: FormSubmitFunction; + parseResult: FormSubmitResult; +} -module.exports = function ImportExportForm({ form, submitParse, parseResult }) { - const [submitExport, exportResult] = useFormSubmit(form, query.useExportDomainListMutation()); +export default function ImportExportForm({ form, submitParse, parseResult }: ImportExportFormProps) { + const [submitExport, exportResult] = useFormSubmit(form, useExportDomainListMutation()); function fileChanged(e) { const reader = new FileReader(); reader.onload = function (read) { - form.domains.value = read.target.result; - submitParse(); + const res = read.target?.result; + if (typeof res === "string") { + form.domains.value = res; + submitParse(); + } }; reader.readAsText(e.target.files[0]); } - React.useEffect(() => { + useEffect(() => { if (exportResult.isSuccess) { form.domains.setter(exportResult.data); } @@ -53,12 +76,10 @@ module.exports = function ImportExportForm({ form, submitParse, parseResult }) { 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> + <h1>Import / Export domain permissions</h1> + <p>This page can be used to import and export lists of domain permissions.</p> + <p>Exports can be done in various formats, with varying functionality and support in other software.</p> + <p>Imports will automatically detect what format is being processed.</p> <ExportFormatTable /> <div className="import-export"> <TextArea @@ -68,6 +89,10 @@ module.exports = function ImportExportForm({ form, submitParse, parseResult }) { rows={8} /> + <RadioGroup + field={form.permType} + /> + <div className="button-grid"> <MutationButton label="Import" @@ -75,6 +100,7 @@ module.exports = function ImportExportForm({ form, submitParse, parseResult }) { onClick={() => submitParse()} result={parseResult} showError={false} + disabled={false} /> <label className="button with-icon"> <i className="fa fa-fw " aria-hidden="true" /> @@ -92,6 +118,7 @@ module.exports = function ImportExportForm({ form, submitParse, parseResult }) { type="button" onClick={() => submitExport("export")} result={exportResult} showError={false} + disabled={false} /> <MutationButton label="Export to file" @@ -100,6 +127,7 @@ module.exports = function ImportExportForm({ form, submitParse, parseResult }) { onClick={() => submitExport("export-file")} result={exportResult} showError={false} + disabled={false} /> <div className="export-file"> <span> @@ -121,4 +149,4 @@ module.exports = function ImportExportForm({ form, submitParse, parseResult }) { </div> </> ); -};
\ No newline at end of file +} diff --git a/web/source/settings/admin/domain-permissions/import-export.tsx b/web/source/settings/admin/domain-permissions/import-export.tsx new file mode 100644 index 000000000..871bca131 --- /dev/null +++ b/web/source/settings/admin/domain-permissions/import-export.tsx @@ -0,0 +1,90 @@ +/* + GoToSocial + Copyright (C) GoToSocial Authors admin@gotosocial.org + SPDX-License-Identifier: AGPL-3.0-or-later + + 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/>. +*/ + +import React from "react"; + +import { Switch, Route, Redirect, useLocation } from "wouter"; + +import { useProcessDomainPermissionsMutation } from "../../lib/query/admin/domain-permissions/process"; + +import { useTextInput, useRadioInput } from "../../lib/form"; + +import useFormSubmit from "../../lib/form/submit"; + +import { ProcessImport } from "./process"; +import ImportExportForm from "./form"; + +export default function ImportExport({ baseUrl }) { + const form = { + domains: useTextInput("domains"), + exportType: useTextInput("exportType", { defaultValue: "plain", dontReset: true }), + permType: useRadioInput("permType", { + options: { + block: "Domain blocks", + allow: "Domain allows", + } + }) + }; + + const [submitParse, parseResult] = useFormSubmit(form, useProcessDomainPermissionsMutation(), { changedOnly: false }); + + const [_location, setLocation] = useLocation(); + + return ( + <Switch> + <Route path={`${baseUrl}/process`}> + { + parseResult.isSuccess + ? ( + <> + <h1> + <span + className="button" + onClick={() => { + parseResult.reset(); + setLocation(baseUrl); + }} + > + < back + </span> + Confirm import of domain {form.permType.value}s: + </h1> + <ProcessImport + list={parseResult.data} + permType={form.permType} + /> + </> + ) + : <Redirect to={baseUrl} /> + } + </Route> + <Route> + { + parseResult.isSuccess + ? <Redirect to={`${baseUrl}/process`} /> + : <ImportExportForm + form={form} + submitParse={submitParse} + parseResult={parseResult} + /> + } + </Route> + </Switch> + ); +} diff --git a/web/source/settings/admin/domain-permissions/index.tsx b/web/source/settings/admin/domain-permissions/index.tsx new file mode 100644 index 000000000..7d790cfc8 --- /dev/null +++ b/web/source/settings/admin/domain-permissions/index.tsx @@ -0,0 +1,49 @@ +/* + GoToSocial + Copyright (C) GoToSocial Authors admin@gotosocial.org + SPDX-License-Identifier: AGPL-3.0-or-later + + 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/>. +*/ + +import React from "react"; +import { Switch, Route } from "wouter"; + +import DomainPermissionsOverview from "./overview"; +import { PermType } from "../../lib/types/domain-permission"; +import DomainPermDetail from "./detail"; + +export default function DomainPermissions({ baseUrl }: { baseUrl: string }) { + return ( + <Switch> + <Route path="/settings/admin/domain-permissions/:permType/:domain"> + {params => ( + <DomainPermDetail + permType={params.permType as PermType} + baseUrl={baseUrl} + domain={params.domain} + /> + )} + </Route> + <Route path="/settings/admin/domain-permissions/:permType"> + {params => ( + <DomainPermissionsOverview + permType={params.permType as PermType} + baseUrl={baseUrl} + /> + )} + </Route> + </Switch> + ); +} diff --git a/web/source/settings/admin/domain-permissions/overview.tsx b/web/source/settings/admin/domain-permissions/overview.tsx new file mode 100644 index 000000000..a37ec9184 --- /dev/null +++ b/web/source/settings/admin/domain-permissions/overview.tsx @@ -0,0 +1,198 @@ +/* + GoToSocial + Copyright (C) GoToSocial Authors admin@gotosocial.org + SPDX-License-Identifier: AGPL-3.0-or-later + + 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/>. +*/ + +import React from "react"; + +import { useMemo } from "react"; +import { Link, useLocation } from "wouter"; +import { matchSorter } from "match-sorter"; + +import { useTextInput } from "../../lib/form"; + +import { TextInput } from "../../components/form/inputs"; + +import Loading from "../../components/loading"; +import { useDomainAllowsQuery, useDomainBlocksQuery } from "../../lib/query/admin/domain-permissions/get"; +import type { MappedDomainPerms, PermType } from "../../lib/types/domain-permission"; +import { NoArg } from "../../lib/types/query"; + +export interface DomainPermissionsOverviewProps { + // Params injected by + // the wouter router. + permType: PermType; + baseUrl: string, +} + +export default function DomainPermissionsOverview({ permType, baseUrl }: DomainPermissionsOverviewProps) { + if (permType !== "block" && permType !== "allow") { + throw "unrecognized perm type " + permType; + } + + // Uppercase first letter of given permType. + const permTypeUpper = useMemo(() => { + return permType.charAt(0).toUpperCase() + permType.slice(1); + }, [permType]); + + // Fetch / wait for desired perms to load. + const { data: blocks, isLoading: isLoadingBlocks } = useDomainBlocksQuery(NoArg, { skip: permType !== "block" }); + const { data: allows, isLoading: isLoadingAllows } = useDomainAllowsQuery(NoArg, { skip: permType !== "allow" }); + + let data: MappedDomainPerms | undefined; + let isLoading: boolean; + + if (permType == "block") { + data = blocks; + isLoading = isLoadingBlocks; + } else { + data = allows; + isLoading = isLoadingAllows; + } + + if (isLoading || data === undefined) { + return <Loading />; + } + + return ( + <div> + <h1>Domain {permTypeUpper}s</h1> + { permType == "block" ? <BlockHelperText/> : <AllowHelperText/> } + <DomainPermsList + data={data} + baseUrl={baseUrl} + permType={permType} + permTypeUpper={permTypeUpper} + /> + <Link to={`${baseUrl}/import-export`}> + <a>Or use the bulk import/export interface</a> + </Link> + </div> + ); +} + +interface DomainPermsListProps { + data: MappedDomainPerms; + baseUrl: string; + permType: PermType; + permTypeUpper: string; +} + +function DomainPermsList({ data, baseUrl, permType, permTypeUpper }: DomainPermsListProps) { + // Format perms into a list. + const perms = useMemo(() => { + return Object.values(data); + }, [data]); + + const [_location, setLocation] = useLocation(); + const filterField = useTextInput("filter"); + + function filterFormSubmit(e) { + e.preventDefault(); + setLocation(`${baseUrl}/${filter}`); + } + + const filter = filterField.value ?? ""; + const filteredPerms = useMemo(() => { + return matchSorter(perms, filter, { keys: ["domain"] }); + }, [perms, filter]); + const filtered = perms.length - filteredPerms.length; + + const filterInfo = ( + <span> + {perms.length} {permType}ed domain{perms.length != 1 ? "s" : ""} {filtered > 0 && `(${filtered} filtered by search)`} + </span> + ); + + const entries = filteredPerms.map((entry) => { + return ( + <Link key={entry.domain} to={`${baseUrl}/${entry.domain}`}> + <a className="entry nounderline"> + <span id="domain">{entry.domain}</span> + <span id="date">{new Date(entry.created_at ?? "").toLocaleString()}</span> + </a> + </Link> + ); + }); + + return ( + <div className="domain-permissions-list"> + <form className="filter" role="search" onSubmit={filterFormSubmit}> + <TextInput + field={filterField} + placeholder="example.org" + label={`Search or add domain ${permType}`} + /> + <Link to={`${baseUrl}/${filter}`}> + <a className="button">{permTypeUpper} {filter}</a> + </Link> + </form> + <div> + {filterInfo} + <div className="list"> + <div className="entries scrolling"> + {entries} + </div> + </div> + </div> + </div> + ); +} + +function BlockHelperText() { + return ( + <p> + Blocking a domain blocks interaction between your instance, and all current and future accounts on + instance(s) running on the blocked domain. Stored content will be removed, and no more data is sent to + the remote server. This extends to all subdomains as well, so blocking 'example.com' also blocks 'social.example.com'. + <br/> + <a + href="https://docs.gotosocial.org/en/latest/admin/domain_blocks/" + target="_blank" + className="docslink" + rel="noreferrer" + > + Learn more about domain blocks (opens in a new tab) + </a> + <br/> + </p> + ); +} + +function AllowHelperText() { + return ( + <p> + Allowing a domain explicitly allows instance(s) running on that domain to interact with your instance. + If you're running in allowlist mode, this is how you "allow" instances through. + If you're running in blocklist mode (the default federation mode), you can use explicit domain allows + to override domain blocks. In blocklist mode, explicitly allowed instances will be able to interact with + your instance regardless of any domain blocks in place. This extends to all subdomains as well, so allowing + 'example.com' also allows 'social.example.com'. This is useful when you're importing a block list but + there are some domains on the list you don't want to block: just create an explicit allow for those domains + before importing the list. + <br/> + <a + href="https://docs.gotosocial.org/en/latest/admin/federation_modes/" + target="_blank" + className="docslink" + rel="noreferrer" + > + Learn more about federation modes (opens in a new tab) + </a> + </p> + ); +} diff --git a/web/source/settings/admin/federation/import-export/process.jsx b/web/source/settings/admin/domain-permissions/process.tsx index b39410605..bb9411b9d 100644 --- a/web/source/settings/admin/federation/import-export/process.jsx +++ b/web/source/settings/admin/domain-permissions/process.tsx @@ -17,57 +17,81 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. */ -const React = require("react"); +import React from "react"; -const query = require("../../../lib/query"); -const { isValidDomainBlock, hasBetterScope } = require("../../../lib/domain-block"); +import { memo, useMemo, useCallback, useEffect } from "react"; -const { +import { isValidDomainPermission, hasBetterScope } from "../../lib/util/domain-permission"; + +import { useTextInput, useBoolInput, useRadioInput, - useCheckListInput -} = require("../../../lib/form"); - -const useFormSubmit = require("../../../lib/form/submit"); + useCheckListInput, +} from "../../lib/form"; -const { - TextInput, +import { + Select, TextArea, + RadioGroup, Checkbox, - Select, - RadioGroup -} = require("../../../components/form/inputs"); + TextInput, +} from "../../components/form/inputs"; + +import useFormSubmit from "../../lib/form/submit"; -const CheckList = require("../../../components/check-list"); -const MutationButton = require("../../../components/form/mutation-button"); -const FormWithData = require("../../../lib/form/form-with-data"); +import CheckList from "../../components/check-list"; +import MutationButton from "../../components/form/mutation-button"; +import FormWithData from "../../lib/form/form-with-data"; -module.exports = React.memo( - function ProcessImport({ list }) { +import { useImportDomainPermsMutation } from "../../lib/query/admin/domain-permissions/import"; +import { + useDomainAllowsQuery, + useDomainBlocksQuery +} from "../../lib/query/admin/domain-permissions/get"; + +import type { DomainPerm, MappedDomainPerms } from "../../lib/types/domain-permission"; +import type { ChecklistInputHook, RadioFormInputHook } from "../../lib/form/types"; + +export interface ProcessImportProps { + list: DomainPerm[], + permType: RadioFormInputHook, +} + +export const ProcessImport = memo( + function ProcessImport({ list, permType }: ProcessImportProps) { return ( <div className="without-border"> <FormWithData - dataQuery={query.useInstanceBlocksQuery} + dataQuery={permType.value == "allow" + ? useDomainAllowsQuery + : useDomainBlocksQuery + } DataForm={ImportList} - list={list} + {...{ list, permType }} /> </div> ); } ); -function ImportList({ list, data: blockedInstances }) { - const hasComment = React.useMemo(() => { +export interface ImportListProps { + list: Array<DomainPerm>, + data: MappedDomainPerms, + permType: RadioFormInputHook, +} + +function ImportList({ list, data: domainPerms, permType }: ImportListProps) { + const hasComment = useMemo(() => { let hasPublic = false; let hasPrivate = false; list.some((entry) => { - if (entry.public_comment?.length > 0) { + if (entry.public_comment) { hasPublic = true; } - if (entry.private_comment?.length > 0) { + if (entry.private_comment) { hasPrivate = true; } @@ -88,7 +112,7 @@ function ImportList({ list, data: blockedInstances }) { const showComment = useTextInput("showComment", { defaultValue: hasComment.type ?? "public_comment" }); const form = { - domains: useCheckListInput("domains", { entries: list }), + domains: useCheckListInput("domains", { entries: list }), // DomainPerm is actually also a Checkable. obfuscate: useBoolInput("obfuscate"), privateComment: useTextInput("private_comment", { defaultValue: `Imported on ${new Date().toLocaleString()}` @@ -108,13 +132,17 @@ function ImportList({ list, data: blockedInstances }) { replace: "Replace" } }), + permType: permType, }; - const [importDomains, importResult] = useFormSubmit(form, query.useImportDomainListMutation(), { changedOnly: false }); + const [importDomains, importResult] = useFormSubmit(form, useImportDomainPermsMutation(), { changedOnly: false }); return ( <> - <form onSubmit={importDomains} className="suspend-import-list"> + <form + onSubmit={importDomains} + className="domain-perm-import-list" + > <span>{list.length} domain{list.length != 1 ? "s" : ""} in this list</span> {hasComment.both && @@ -129,8 +157,9 @@ function ImportList({ list, data: blockedInstances }) { <div className="checkbox-list-wrapper"> <DomainCheckList field={form.domains} - blockedInstances={blockedInstances} - commentType={showComment.value} + domainPerms={domainPerms} + commentType={showComment.value as "public_comment" | "private_comment"} + permType={form.permType} /> </div> @@ -159,28 +188,41 @@ function ImportList({ list, data: blockedInstances }) { label="Obfuscate domains in public lists" /> - <MutationButton label="Import" result={importResult} /> + <MutationButton + label="Import" + disabled={false} + result={importResult} + /> </form> </> ); } -function DomainCheckList({ field, blockedInstances, commentType }) { - const getExtraProps = React.useCallback((entry) => { +interface DomainCheckListProps { + field: ChecklistInputHook, + domainPerms: MappedDomainPerms, + commentType: "public_comment" | "private_comment", + permType: RadioFormInputHook, +} + +function DomainCheckList({ field, domainPerms, commentType, permType }: DomainCheckListProps) { + const getExtraProps = useCallback((entry: DomainPerm) => { return { comment: entry[commentType], - alreadyExists: blockedInstances[entry.domain] != undefined + alreadyExists: entry.domain in domainPerms, + permType: permType, }; - }, [blockedInstances, commentType]); + }, [domainPerms, commentType, permType]); - const entriesWithSuggestions = React.useMemo(() => ( - Object.values(field.value).filter((entry) => entry.suggest) - ), [field.value]); + const entriesWithSuggestions = useMemo(() => { + const fieldValue = (field.value ?? {}) as { [k: string]: DomainPerm; }; + return Object.values(fieldValue).filter((entry) => entry.suggest); + }, [field.value]); return ( <> <CheckList - field={field} + field={field as ChecklistInputHook} header={<> <b>Domain</b> <b> @@ -200,8 +242,14 @@ function DomainCheckList({ field, blockedInstances, commentType }) { ); } -const UpdateHint = React.memo( - function UpdateHint({ entries, updateEntry, updateMultiple }) { +interface UpdateHintProps { + entries, + updateEntry, + updateMultiple, +} + +const UpdateHint = memo( + function UpdateHint({ entries, updateEntry, updateMultiple }: UpdateHintProps) { if (entries.length == 0) { return null; } @@ -229,8 +277,13 @@ const UpdateHint = React.memo( } ); -const UpdateableEntry = React.memo( - function UpdateableEntry({ entry, updateEntry }) { +interface UpdateableEntryProps { + entry, + updateEntry, +} + +const UpdateableEntry = memo( + function UpdateableEntry({ entry, updateEntry }: UpdateableEntryProps) { return ( <> <span className="text-cutoff">{entry.domain}</span> @@ -248,21 +301,31 @@ function domainValidationError(isValid) { return isValid ? "" : "Invalid domain"; } -function DomainEntry({ entry, onChange, extraProps: { alreadyExists, comment } }) { +interface DomainEntryProps { + entry; + onChange; + extraProps: { + alreadyExists: boolean; + comment: string; + permType: RadioFormInputHook; + }; +} + +function DomainEntry({ entry, onChange, extraProps: { alreadyExists, comment, permType } }: DomainEntryProps) { const domainField = useTextInput("domain", { defaultValue: entry.domain, showValidation: entry.checked, initValidation: domainValidationError(entry.valid), - validator: (value) => domainValidationError(isValidDomainBlock(value)) + validator: (value) => domainValidationError(isValidDomainPermission(value)) }); - React.useEffect(() => { + useEffect(() => { if (entry.valid != domainField.valid) { onChange({ valid: domainField.valid }); } }, [onChange, entry.valid, domainField.valid]); - React.useEffect(() => { + useEffect(() => { if (entry.domain != domainField.value) { domainField.setter(entry.domain); } @@ -270,8 +333,8 @@ function DomainEntry({ entry, onChange, extraProps: { alreadyExists, comment } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [entry.domain, domainField.setter]); - React.useEffect(() => { - onChange({ suggest: hasBetterScope(domainField.value) }); + 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]); @@ -296,7 +359,11 @@ function DomainEntry({ entry, onChange, extraProps: { alreadyExists, comment } } }} /> <span id="icon" onClick={clickIcon}> - <DomainEntryIcon alreadyExists={alreadyExists} suggestion={entry.suggest} onChange={onChange} /> + <DomainEntryIcon + alreadyExists={alreadyExists} + suggestion={entry.suggest} + permTypeString={permType.value?? ""} + /> </span> </div> <p>{comment}</p> @@ -304,7 +371,13 @@ function DomainEntry({ entry, onChange, extraProps: { alreadyExists, comment } } ); } -function DomainEntryIcon({ alreadyExists, suggestion }) { +interface DomainEntryIconProps { + alreadyExists: boolean; + suggestion: string; + permTypeString: string; +} + +function DomainEntryIcon({ alreadyExists, suggestion, permTypeString }: DomainEntryIconProps) { let icon; let text; @@ -312,8 +385,8 @@ function DomainEntryIcon({ alreadyExists, 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."; + icon = "fa-history permission-already-exists"; + text = `Domain ${permTypeString} already exists.`; } if (!icon) { diff --git a/web/source/settings/admin/emoji/category-select.jsx b/web/source/settings/admin/emoji/category-select.jsx index da2604602..e5cf29939 100644 --- a/web/source/settings/admin/emoji/category-select.jsx +++ b/web/source/settings/admin/emoji/category-select.jsx @@ -22,9 +22,8 @@ const splitFilterN = require("split-filter-n"); const syncpipe = require('syncpipe'); const { matchSorter } = require("match-sorter"); -const query = require("../../lib/query"); - const ComboBox = require("../../components/combo-box"); +const { useListEmojiQuery } = require("../../lib/query/admin/custom-emoji"); function useEmojiByCategory(emoji) { // split all emoji over an object keyed by the category names (or Unsorted) @@ -43,7 +42,7 @@ function CategorySelect({ field, children }) { isLoading, isSuccess, error - } = query.useListEmojiQuery({ filter: "domain:local" }); + } = useListEmojiQuery({ filter: "domain:local" }); const emojiByCategory = useEmojiByCategory(emoji); diff --git a/web/source/settings/admin/emoji/local/detail.js b/web/source/settings/admin/emoji/local/detail.js index daf7a2dac..18a681b6e 100644 --- a/web/source/settings/admin/emoji/local/detail.js +++ b/web/source/settings/admin/emoji/local/detail.js @@ -20,21 +20,25 @@ const React = require("react"); const { useRoute, Link, Redirect } = require("wouter"); -const query = require("../../../lib/query"); - const { useComboBoxInput, useFileInput, useValue } = require("../../../lib/form"); const { CategorySelect } = require("../category-select"); -const useFormSubmit = require("../../../lib/form/submit"); +const useFormSubmit = require("../../../lib/form/submit").default; const { useBaseUrl } = require("../../../lib/navigation/util"); const FakeToot = require("../../../components/fake-toot"); -const FormWithData = require("../../../lib/form/form-with-data"); +const FormWithData = require("../../../lib/form/form-with-data").default; const Loading = require("../../../components/loading"); const { FileInput } = require("../../../components/form/inputs"); const MutationButton = require("../../../components/form/mutation-button"); const { Error } = require("../../../components/error"); +const { + useGetEmojiQuery, + useEditEmojiMutation, + useDeleteEmojiMutation, +} = require("../../../lib/query/admin/custom-emoji"); + module.exports = function EmojiDetailRoute({ }) { const baseUrl = useBaseUrl(); let [_match, params] = useRoute(`${baseUrl}/:emojiId`); @@ -44,7 +48,7 @@ module.exports = function EmojiDetailRoute({ }) { return ( <div className="emoji-detail"> <Link to={baseUrl}><a>< go back</a></Link> - <FormWithData dataQuery={query.useGetEmojiQuery} queryArg={params.emojiId} DataForm={EmojiDetailForm} /> + <FormWithData dataQuery={useGetEmojiQuery} queryArg={params.emojiId} DataForm={EmojiDetailForm} /> </div> ); } @@ -61,7 +65,7 @@ function EmojiDetailForm({ data: emoji }) { }) }; - const [modifyEmoji, result] = useFormSubmit(form, query.useEditEmojiMutation()); + const [modifyEmoji, result] = useFormSubmit(form, useEditEmojiMutation()); // Automatic submitting of category change React.useEffect(() => { @@ -74,7 +78,7 @@ function EmojiDetailForm({ data: emoji }) { /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [form.category.hasChanged(), form.category.isNew, form.category.state.open]); - const [deleteEmoji, deleteResult] = query.useDeleteEmojiMutation(); + const [deleteEmoji, deleteResult] = useDeleteEmojiMutation(); if (deleteResult.isSuccess) { return <Redirect to={baseUrl} />; diff --git a/web/source/settings/admin/emoji/local/new-emoji.js b/web/source/settings/admin/emoji/local/new-emoji.js index 439d09e62..ecb0465cb 100644 --- a/web/source/settings/admin/emoji/local/new-emoji.js +++ b/web/source/settings/admin/emoji/local/new-emoji.js @@ -19,15 +19,13 @@ const React = require("react"); -const query = require("../../../lib/query"); - const { useFileInput, useComboBoxInput } = require("../../../lib/form"); const useShortcode = require("./use-shortcode"); -const useFormSubmit = require("../../../lib/form/submit"); +const useFormSubmit = require("../../../lib/form/submit").default; const { TextInput, FileInput @@ -36,11 +34,13 @@ const { const { CategorySelect } = require('../category-select'); const FakeToot = require("../../../components/fake-toot"); const MutationButton = require("../../../components/form/mutation-button"); +const { useAddEmojiMutation } = require("../../../lib/query/admin/custom-emoji"); +const { useInstanceV1Query } = require("../../../lib/query"); module.exports = function NewEmojiForm() { const shortcode = useShortcode(); - const { data: instance } = query.useInstanceQuery(); + const { data: instance } = useInstanceV1Query(); const emojiMaxSize = React.useMemo(() => { return instance?.configuration?.emojis?.emoji_size_limit ?? 50 * 1024; }, [instance]); @@ -54,7 +54,7 @@ module.exports = function NewEmojiForm() { const [submitForm, result] = useFormSubmit({ shortcode, image, category - }, query.useAddEmojiMutation()); + }, useAddEmojiMutation()); React.useEffect(() => { if (shortcode.value.length == 0) { diff --git a/web/source/settings/admin/emoji/local/overview.js b/web/source/settings/admin/emoji/local/overview.js index 38dc1feba..757f07c43 100644 --- a/web/source/settings/admin/emoji/local/overview.js +++ b/web/source/settings/admin/emoji/local/overview.js @@ -25,13 +25,13 @@ const { matchSorter } = require("match-sorter"); const NewEmojiForm = require("./new-emoji"); const { useTextInput } = require("../../../lib/form"); -const query = require("../../../lib/query"); const { useEmojiByCategory } = require("../category-select"); const { useBaseUrl } = require("../../../lib/navigation/util"); const Loading = require("../../../components/loading"); const { Error } = require("../../../components/error"); const { TextInput } = require("../../../components/form/inputs"); +const { useListEmojiQuery } = require("../../../lib/query/admin/custom-emoji"); module.exports = function EmojiOverview({ }) { const { @@ -39,7 +39,7 @@ module.exports = function EmojiOverview({ }) { isLoading, isError, error - } = query.useListEmojiQuery({ filter: "domain:local" }); + } = useListEmojiQuery({ filter: "domain:local" }); let content = null; diff --git a/web/source/settings/admin/emoji/local/use-shortcode.js b/web/source/settings/admin/emoji/local/use-shortcode.js index 7e1bae0ad..67255860f 100644 --- a/web/source/settings/admin/emoji/local/use-shortcode.js +++ b/web/source/settings/admin/emoji/local/use-shortcode.js @@ -19,15 +19,15 @@ const React = require("react"); -const query = require("../../../lib/query"); const { useTextInput } = require("../../../lib/form"); +const { useListEmojiQuery } = require("../../../lib/query/admin/custom-emoji"); const shortcodeRegex = /^\w{2,30}$/; module.exports = function useShortcode() { - const { - data: emoji = [] - } = query.useListEmojiQuery({ filter: "domain:local" }); + const { data: emoji = [] } = useListEmojiQuery({ + filter: "domain:local" + }); const emojiCodes = React.useMemo(() => { return new Set(emoji.map((e) => e.shortcode)); diff --git a/web/source/settings/admin/emoji/remote/index.js b/web/source/settings/admin/emoji/remote/index.js index e877efb89..1a8c719dd 100644 --- a/web/source/settings/admin/emoji/remote/index.js +++ b/web/source/settings/admin/emoji/remote/index.js @@ -21,9 +21,9 @@ const React = require("react"); const ParseFromToot = require("./parse-from-toot"); -const query = require("../../../lib/query"); const Loading = require("../../../components/loading"); const { Error } = require("../../../components/error"); +const { useListEmojiQuery } = require("../../../lib/query/admin/custom-emoji"); module.exports = function RemoteEmoji() { // local emoji are queried for shortcode collision detection @@ -31,7 +31,7 @@ module.exports = function RemoteEmoji() { data: emoji = [], isLoading, error - } = query.useListEmojiQuery({ filter: "domain:local" }); + } = useListEmojiQuery({ filter: "domain:local" }); const emojiCodes = React.useMemo(() => { return new Set(emoji.map((e) => e.shortcode)); diff --git a/web/source/settings/admin/emoji/remote/parse-from-toot.js b/web/source/settings/admin/emoji/remote/parse-from-toot.js index e6438a4d2..503a341c8 100644 --- a/web/source/settings/admin/emoji/remote/parse-from-toot.js +++ b/web/source/settings/admin/emoji/remote/parse-from-toot.js @@ -19,25 +19,27 @@ const React = require("react"); -const query = require("../../../lib/query"); - const { useTextInput, useComboBoxInput, useCheckListInput } = require("../../../lib/form"); -const useFormSubmit = require("../../../lib/form/submit"); +const useFormSubmit = require("../../../lib/form/submit").default; -const CheckList = require("../../../components/check-list"); +const CheckList = require("../../../components/check-list").default; const { CategorySelect } = require('../category-select'); const { TextInput } = require("../../../components/form/inputs"); const MutationButton = require("../../../components/form/mutation-button"); const { Error } = require("../../../components/error"); +const { + useSearchItemForEmojiMutation, + usePatchRemoteEmojisMutation +} = require("../../../lib/query/admin/custom-emoji"); module.exports = function ParseFromToot({ emojiCodes }) { - const [searchStatus, result] = query.useSearchStatusForEmojiMutation(); + const [searchStatus, result] = useSearchItemForEmojiMutation(); const [onURLChange, _resetURL, { url }] = useTextInput("url"); @@ -121,7 +123,7 @@ function CopyEmojiForm({ localEmojiCodes, type, emojiList }) { const [formSubmit, result] = useFormSubmit( form, - query.usePatchRemoteEmojisMutation(), + usePatchRemoteEmojisMutation(), { changedOnly: false, onFinish: ({ data }) => { diff --git a/web/source/settings/admin/federation/detail.js b/web/source/settings/admin/federation/detail.js deleted file mode 100644 index 7bdee66cf..000000000 --- a/web/source/settings/admin/federation/detail.js +++ /dev/null @@ -1,168 +0,0 @@ -/* - GoToSocial - Copyright (C) GoToSocial Authors admin@gotosocial.org - SPDX-License-Identifier: AGPL-3.0-or-later - - 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/>. -*/ - -const React = require("react"); -const { useRoute, Redirect, useLocation } = require("wouter"); - -const query = require("../../lib/query"); - -const { useTextInput, useBoolInput } = require("../../lib/form"); - -const useFormSubmit = require("../../lib/form/submit"); - -const { TextInput, Checkbox, TextArea } = require("../../components/form/inputs"); - -const Loading = require("../../components/loading"); -const BackButton = require("../../components/back-button"); -const MutationButton = require("../../components/form/mutation-button"); - -module.exports = function InstanceDetail({ baseUrl }) { - const { data: blockedInstances = {}, isLoading } = query.useInstanceBlocksQuery(); - - let [_match, { domain }] = useRoute(`${baseUrl}/:domain`); - if (domain == "view") { - // Retrieve domain from form field submission. - domain = (new URL(document.location)).searchParams.get("domain"); - } - - // Normalize / decode domain (it may be URL-encoded). - domain = decodeURIComponent(domain); - - const existingBlock = React.useMemo(() => { - return blockedInstances[domain]; - }, [blockedInstances, domain]); - - if (domain == undefined) { - return <Redirect to={baseUrl} />; - } - - let infoContent = null; - - if (isLoading) { - infoContent = <Loading />; - } else if (existingBlock == undefined) { - infoContent = <span>No stored block yet, you can add one below:</span>; - } else { - infoContent = ( - <div className="info"> - <i className="fa fa-fw fa-exclamation-triangle" aria-hidden="true"></i> - <b>Editing domain blocks isn't implemented yet, <a href="https://github.com/superseriousbusiness/gotosocial/issues/1198" target="_blank" rel="noopener noreferrer">check here for progress</a></b> - </div> - ); - } - - return ( - <div> - <h1 className="text-cutoff"><BackButton to={baseUrl} /> Federation settings for: <span title={domain}>{domain}</span></h1> - {infoContent} - <DomainBlockForm defaultDomain={domain} block={existingBlock} baseUrl={baseUrl} /> - </div> - ); -}; - -function DomainBlockForm({ defaultDomain, block = {}, baseUrl }) { - const isExistingBlock = block.domain != undefined; - - const disabledForm = isExistingBlock - ? { - disabled: true, - title: "Domain suspensions currently cannot be edited." - } - : {}; - - const form = { - domain: useTextInput("domain", { source: block, defaultValue: defaultDomain }), - obfuscate: useBoolInput("obfuscate", { source: block }), - commentPrivate: useTextInput("private_comment", { source: block }), - commentPublic: useTextInput("public_comment", { source: block }) - }; - - const [submitForm, addResult] = useFormSubmit(form, query.useAddInstanceBlockMutation(), { changedOnly: false }); - - const [removeBlock, removeResult] = query.useRemoveInstanceBlockMutation({ fixedCacheKey: block.id }); - - const [location, setLocation] = useLocation(); - - function verifyUrlThenSubmit(e) { - // Adding a new block happens on /settings/admin/federation/domain.com - // but if domain input changes, that doesn't match anymore and causes issues later on - // so, before submitting the form, silently change url, then submit - let correctUrl = `${baseUrl}/${form.domain.value}`; - if (location != correctUrl) { - setLocation(correctUrl); - } - return submitForm(e); - } - - return ( - <form onSubmit={verifyUrlThenSubmit}> - <TextInput - field={form.domain} - label="Domain" - placeholder="example.com" - {...disabledForm} - /> - - <Checkbox - field={form.obfuscate} - label="Obfuscate domain in public lists" - {...disabledForm} - /> - - <TextArea - field={form.commentPrivate} - label="Private comment" - rows={3} - {...disabledForm} - /> - - <TextArea - field={form.commentPublic} - label="Public comment" - rows={3} - {...disabledForm} - /> - - <div className="action-buttons row"> - <MutationButton - label="Suspend" - result={addResult} - showError={false} - {...disabledForm} - /> - - { - isExistingBlock && - <MutationButton - type="button" - onClick={() => removeBlock(block.id)} - label="Remove" - result={removeResult} - className="button danger" - showError={false} - /> - } - </div> - - {addResult.error && <Error error={addResult.error} />} - {removeResult.error && <Error error={removeResult.error} />} - - </form> - ); -}
\ No newline at end of file diff --git a/web/source/settings/admin/federation/import-export/index.jsx b/web/source/settings/admin/federation/import-export/index.jsx deleted file mode 100644 index bff14b939..000000000 --- a/web/source/settings/admin/federation/import-export/index.jsx +++ /dev/null @@ -1,75 +0,0 @@ -/* - GoToSocial - Copyright (C) GoToSocial Authors admin@gotosocial.org - SPDX-License-Identifier: AGPL-3.0-or-later - - 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/>. -*/ - -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"); - -module.exports = function ImportExport({ baseUrl }) { - const form = { - domains: useTextInput("domains"), - exportType: useTextInput("exportType", { defaultValue: "plain", dontReset: true }) - }; - - const [submitParse, parseResult] = useFormSubmit(form, query.useProcessDomainListMutation(), { changedOnly: false }); - - const [_location, setLocation] = useLocation(); - - return ( - <Switch> - <Route path={`${baseUrl}/process`}> - {parseResult.isSuccess ? ( - <> - <h1> - <span className="button" onClick={() => { - parseResult.reset(); - setLocation(baseUrl); - }}> - < 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> - ); -};
\ No newline at end of file diff --git a/web/source/settings/admin/federation/overview.js b/web/source/settings/admin/federation/overview.js deleted file mode 100644 index c09289284..000000000 --- a/web/source/settings/admin/federation/overview.js +++ /dev/null @@ -1,101 +0,0 @@ -/* - GoToSocial - Copyright (C) GoToSocial Authors admin@gotosocial.org - SPDX-License-Identifier: AGPL-3.0-or-later - - 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/>. -*/ - -const React = require("react"); -const { Link, useLocation } = require("wouter"); -const { matchSorter } = require("match-sorter"); - -const { useTextInput } = require("../../lib/form"); - -const { TextInput } = require("../../components/form/inputs"); - -const query = require("../../lib/query"); - -const Loading = require("../../components/loading"); - -module.exports = function InstanceOverview({ baseUrl }) { - const { data: blockedInstances = [], isLoading } = query.useInstanceBlocksQuery(); - - const [_location, setLocation] = useLocation(); - - const filterField = useTextInput("filter"); - const filter = filterField.value; - - const blockedInstancesList = React.useMemo(() => { - return Object.values(blockedInstances); - }, [blockedInstances]); - - const filteredInstances = React.useMemo(() => { - return matchSorter(blockedInstancesList, filter, { keys: ["domain"] }); - }, [blockedInstancesList, filter]); - - let filtered = blockedInstancesList.length - filteredInstances.length; - - function filterFormSubmit(e) { - e.preventDefault(); - setLocation(`${baseUrl}/${filter}`); - } - - if (isLoading) { - return <Loading />; - } - - return ( - <> - <h1>Federation</h1> - - <div className="instance-list"> - <h2>Suspended instances</h2> - <p> - Suspending a domain blocks all current and future accounts on that instance. Stored content will be removed, - and no more data is sent to the remote server.<br /> - This extends to all subdomains as well, so blocking 'example.com' also includes 'social.example.com'. - </p> - <form className="filter" role="search" onSubmit={filterFormSubmit}> - <TextInput field={filterField} placeholder="example.com" label="Search or add domain suspension" /> - <Link to={`${baseUrl}/${filter}`}><a className="button">Suspend</a></Link> - </form> - <div> - <span> - {blockedInstancesList.length} blocked instance{blockedInstancesList.length != 1 ? "s" : ""} {filtered > 0 && `(${filtered} filtered by search)`} - </span> - <div className="list"> - <div className="entries scrolling"> - {filteredInstances.map((entry) => { - return ( - <Link key={entry.domain} to={`${baseUrl}/${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> - </div> - </div> - <Link to={`${baseUrl}/import-export`}><a>Or use the bulk import/export interface</a></Link> - </> - ); -};
\ No newline at end of file diff --git a/web/source/settings/admin/reports/detail.jsx b/web/source/settings/admin/reports/detail.jsx index 6b85872c4..d686b92bd 100644 --- a/web/source/settings/admin/reports/detail.jsx +++ b/web/source/settings/admin/reports/detail.jsx @@ -20,19 +20,21 @@ const React = require("react"); const { useRoute, Redirect } = require("wouter"); -const query = require("../../lib/query"); - -const FormWithData = require("../../lib/form/form-with-data"); +const FormWithData = require("../../lib/form/form-with-data").default; const BackButton = require("../../components/back-button"); const { useValue, useTextInput } = require("../../lib/form"); -const useFormSubmit = require("../../lib/form/submit"); +const useFormSubmit = require("../../lib/form/submit").default; const { TextArea } = require("../../components/form/inputs"); const MutationButton = require("../../components/form/mutation-button"); const Username = require("./username"); const { useBaseUrl } = require("../../lib/navigation/util"); +const { + useGetReportQuery, + useResolveReportMutation, +} = require("../../lib/query/admin/reports"); module.exports = function ReportDetail({ }) { const baseUrl = useBaseUrl(); @@ -46,7 +48,7 @@ module.exports = function ReportDetail({ }) { <BackButton to={baseUrl} /> Report Details </h1> <FormWithData - dataQuery={query.useGetReportQuery} + dataQuery={useGetReportQuery} queryArg={params.reportId} DataForm={ReportDetailForm} /> @@ -115,7 +117,7 @@ function ReportActionForm({ report }) { comment: useTextInput("action_taken_comment") }; - const [submit, result] = useFormSubmit(form, query.useResolveReportMutation(), { changedOnly: false }); + const [submit, result] = useFormSubmit(form, useResolveReportMutation(), { changedOnly: false }); return ( <form onSubmit={submit} className="info-block"> diff --git a/web/source/settings/admin/reports/index.jsx b/web/source/settings/admin/reports/index.jsx index 2f7a09517..5ffbfd3a0 100644 --- a/web/source/settings/admin/reports/index.jsx +++ b/web/source/settings/admin/reports/index.jsx @@ -20,13 +20,12 @@ const React = require("react"); const { Link, Switch, Route } = require("wouter"); -const query = require("../../lib/query"); - -const FormWithData = require("../../lib/form/form-with-data"); +const FormWithData = require("../../lib/form/form-with-data").default; const ReportDetail = require("./detail"); const Username = require("./username"); const { useBaseUrl } = require("../../lib/navigation/util"); +const { useListReportsQuery } = require("../../lib/query/admin/reports"); module.exports = function Reports({ baseUrl }) { return ( @@ -51,7 +50,7 @@ function ReportOverview({ }) { </p> </div> <FormWithData - dataQuery={query.useListReportsQuery} + dataQuery={useListReportsQuery} DataForm={ReportsList} /> </> diff --git a/web/source/settings/admin/settings/index.jsx b/web/source/settings/admin/settings/index.jsx index 5ea227fb1..c0da83a2a 100644 --- a/web/source/settings/admin/settings/index.jsx +++ b/web/source/settings/admin/settings/index.jsx @@ -19,14 +19,12 @@ const React = require("react"); -const query = require("../../lib/query"); - const { useTextInput, useFileInput } = require("../../lib/form"); -const useFormSubmit = require("../../lib/form/submit"); +const useFormSubmit = require("../../lib/form/submit").default; const { TextInput, @@ -34,13 +32,16 @@ const { FileInput } = require("../../components/form/inputs"); -const FormWithData = require("../../lib/form/form-with-data"); +const FormWithData = require("../../lib/form/form-with-data").default; const MutationButton = require("../../components/form/mutation-button"); +const { useInstanceV1Query } = require("../../lib/query"); +const { useUpdateInstanceMutation } = require("../../lib/query/admin"); + module.exports = function AdminSettings() { return ( <FormWithData - dataQuery={query.useInstanceQuery} + dataQuery={useInstanceV1Query} DataForm={AdminSettingsForm} /> ); @@ -61,7 +62,7 @@ function AdminSettingsForm({ data: instance }) { terms: useTextInput("terms", { source: instance }) }; - const [submitForm, result] = useFormSubmit(form, query.useUpdateInstanceMutation()); + const [submitForm, result] = useFormSubmit(form, useUpdateInstanceMutation()); return ( <form onSubmit={submitForm}> diff --git a/web/source/settings/admin/settings/rules.jsx b/web/source/settings/admin/settings/rules.jsx index 7c78e6601..4280ccea7 100644 --- a/web/source/settings/admin/settings/rules.jsx +++ b/web/source/settings/admin/settings/rules.jsx @@ -21,11 +21,11 @@ const React = require("react"); const { Switch, Route, Link, Redirect, useRoute } = require("wouter"); const query = require("../../lib/query"); -const FormWithData = require("../../lib/form/form-with-data"); +const FormWithData = require("../../lib/form/form-with-data").default; const { useBaseUrl } = require("../../lib/navigation/util"); const { useValue, useTextInput } = require("../../lib/form"); -const useFormSubmit = require("../../lib/form/submit"); +const useFormSubmit = require("../../lib/form/submit").default; const { TextArea } = require("../../components/form/inputs"); const MutationButton = require("../../components/form/mutation-button"); diff --git a/web/source/settings/components/authorization/index.tsx b/web/source/settings/components/authorization/index.tsx index 321bb03eb..22a0d24b7 100644 --- a/web/source/settings/components/authorization/index.tsx +++ b/web/source/settings/components/authorization/index.tsx @@ -25,6 +25,7 @@ import React from "react"; import Login from "./login"; import Loading from "../loading"; import { Error } from "../error"; +import { NoArg } from "../../lib/types/query"; export function Authorization({ App }) { const { loginState, expectingRedirect } = store.getState().oauth; @@ -35,15 +36,15 @@ export function Authorization({ App }) { isSuccess, data: account, error, - } = useVerifyCredentialsQuery(null, { skip: skip }); + } = useVerifyCredentialsQuery(NoArg, { skip: skip }); let showLogin = true; - let content = null; + let content: React.JSX.Element | null = null; if (isLoading) { showLogin = false; - let loadingInfo; + let loadingInfo = ""; if (loginState == "callback") { loadingInfo = "Processing OAUTH callback."; } else if (loginState == "login") { diff --git a/web/source/settings/components/authorization/login.tsx b/web/source/settings/components/authorization/login.tsx index 76bfccf43..870e9c343 100644 --- a/web/source/settings/components/authorization/login.tsx +++ b/web/source/settings/components/authorization/login.tsx @@ -22,26 +22,21 @@ import React from "react"; import { useAuthorizeFlowMutation } from "../../lib/query/oauth"; import { useTextInput, useValue } from "../../lib/form"; import useFormSubmit from "../../lib/form/submit"; -import { TextInput } from "../form/inputs"; import MutationButton from "../form/mutation-button"; import Loading from "../loading"; +import { TextInput } from "../form/inputs"; export default function Login({ }) { const form = { instance: useTextInput("instance", { defaultValue: window.location.origin }), - scopes: useValue("scopes", "user admin") + scopes: useValue("scopes", "user admin"), }; - const [formSubmit, result] = useFormSubmit( - form, - useAuthorizeFlowMutation(), - { - changedOnly: false, - onFinish: undefined, - } - ); + const [formSubmit, result] = useFormSubmit(form, useAuthorizeFlowMutation(), { + changedOnly: false, + }); if (result.isLoading) { return ( diff --git a/web/source/settings/components/check-list.jsx b/web/source/settings/components/check-list.tsx index de42a56a5..aec57e758 100644 --- a/web/source/settings/components/check-list.jsx +++ b/web/source/settings/components/check-list.tsx @@ -17,21 +17,31 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. */ -const React = require("react"); +import React from "react"; -module.exports = function CheckList({ field, header = "All", EntryComponent, getExtraProps }) { +import { memo, useDeferredValue, useCallback, useMemo } from "react"; +import { Checkable, ChecklistInputHook } from "../lib/form/types"; + +interface CheckListProps { + field: ChecklistInputHook; + header: string | React.JSX.Element; + EntryComponent: React.FunctionComponent; + getExtraProps; +} + +export default function CheckList({ field, header = "All", EntryComponent, getExtraProps }: CheckListProps) { return ( <div className="checkbox-list list"> <CheckListHeader toggleAll={field.toggleAll}> {header}</CheckListHeader> <CheckListEntries - entries={field.value} + entries={field.value ?? {}} updateValue={field.onChange} EntryComponent={EntryComponent} getExtraProps={getExtraProps} /> </div> ); -}; +} function CheckListHeader({ toggleAll, children }) { return ( @@ -45,9 +55,16 @@ function CheckListHeader({ toggleAll, children }) { ); } -const CheckListEntries = React.memo( - function CheckListEntries({ entries, updateValue, EntryComponent, getExtraProps }) { - const deferredEntries = React.useDeferredValue(entries); +interface CheckListEntriesProps { + entries: { [_: string]: Checkable }, + updateValue, + EntryComponent, + getExtraProps, +} + +const CheckListEntries = memo( + function CheckListEntries({ entries, updateValue, EntryComponent, getExtraProps }: CheckListEntriesProps) { + const deferredEntries = useDeferredValue(entries); return Object.values(deferredEntries).map((entry) => ( <CheckListEntry @@ -61,19 +78,26 @@ const CheckListEntries = React.memo( } ); +interface CheckListEntryProps { + entry: Checkable, + updateValue, + getExtraProps, + EntryComponent, +} + /* 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( +const CheckListEntry = memo( + function CheckListEntry({ entry, updateValue, getExtraProps, EntryComponent }: CheckListEntryProps) { + const onChange = useCallback( (value) => updateValue(entry.key, value), [updateValue, entry.key] ); - const extraProps = React.useMemo(() => getExtraProps?.(entry), [getExtraProps, entry]); + const extraProps = useMemo(() => getExtraProps?.(entry), [getExtraProps, entry]); return ( <label className="entry"> diff --git a/web/source/settings/components/form/inputs.jsx b/web/source/settings/components/form/inputs.tsx index f7a6beeda..1e0d8eaab 100644 --- a/web/source/settings/components/form/inputs.jsx +++ b/web/source/settings/components/form/inputs.tsx @@ -17,9 +17,28 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. */ -const React = require("react"); +import React from "react"; + +import type { + ReactNode, + RefObject, +} from "react"; + +import type { + FileFormInputHook, + RadioFormInputHook, + TextFormInputHook, +} from "../../lib/form/types"; + +export interface TextInputProps extends React.DetailedHTMLProps< + React.InputHTMLAttributes<HTMLInputElement>, + HTMLInputElement +> { + label?: string; + field: TextFormInputHook; +} -function TextInput({ label, field, ...inputProps }) { +export function TextInput({label, field, ...props}: TextInputProps) { const { onChange, value, ref } = field; return ( @@ -27,16 +46,25 @@ function TextInput({ label, field, ...inputProps }) { <label> {label} <input - type="text" - {...{ onChange, value, ref }} - {...inputProps} + onChange={onChange} + value={value} + ref={ref as RefObject<HTMLInputElement>} + {...props} /> </label> </div> ); } -function TextArea({ label, field, ...inputProps }) { +export interface TextAreaProps extends React.DetailedHTMLProps< + React.TextareaHTMLAttributes<HTMLTextAreaElement>, + HTMLTextAreaElement +> { + label?: string; + field: TextFormInputHook; +} + +export function TextArea({label, field, ...props}: TextAreaProps) { const { onChange, value, ref } = field; return ( @@ -44,16 +72,25 @@ function TextArea({ label, field, ...inputProps }) { <label> {label} <textarea - type="text" - {...{ onChange, value, ref }} - {...inputProps} + onChange={onChange} + value={value} + ref={ref as RefObject<HTMLTextAreaElement>} + {...props} /> </label> </div> ); } -function FileInput({ label, field, ...inputProps }) { +export interface FileInputProps extends React.DetailedHTMLProps< + React.InputHTMLAttributes<HTMLInputElement>, + HTMLInputElement +> { + label?: string; + field: FileFormInputHook; +} + +export function FileInput({ label, field, ...props }: FileInputProps) { const { onChange, ref, infoComponent } = field; return ( @@ -66,15 +103,16 @@ function FileInput({ label, field, ...inputProps }) { <input type="file" className="hidden" - {...{ onChange, ref }} - {...inputProps} + onChange={onChange} + ref={ref ? ref as RefObject<HTMLInputElement> : undefined} + {...props} /> </label> </div> ); } -function Checkbox({ label, field, ...inputProps }) { +export function Checkbox({ label, field, ...inputProps }) { const { onChange, value } = field; return ( @@ -91,16 +129,29 @@ function Checkbox({ label, field, ...inputProps }) { ); } -function Select({ label, field, options, children, ...inputProps }) { +export interface SelectProps extends React.DetailedHTMLProps< + React.SelectHTMLAttributes<HTMLSelectElement>, + HTMLSelectElement +> { + label?: string; + field: TextFormInputHook; + children?: ReactNode; + options: React.JSX.Element; +} + +export function Select({ label, field, children, options, ...props }: SelectProps) { const { onChange, value, ref } = field; return ( <div className="form-field select"> <label> - {label} {children} + {label} + {children} <select - {...{ onChange, value, ref }} - {...inputProps} + onChange={onChange} + value={value} + ref={ref as RefObject<HTMLSelectElement>} + {...props} > {options} </select> @@ -109,7 +160,15 @@ function Select({ label, field, options, children, ...inputProps }) { ); } -function RadioGroup({ field, label, ...inputProps }) { +export interface RadioGroupProps extends React.DetailedHTMLProps< + React.InputHTMLAttributes<HTMLInputElement>, + HTMLInputElement +> { + label?: string; + field: RadioFormInputHook; +} + +export function RadioGroup({ label, field, ...props }: RadioGroupProps) { return ( <div className="form-field radio"> {Object.entries(field.options).map(([value, radioLabel]) => ( @@ -120,7 +179,7 @@ function RadioGroup({ field, label, ...inputProps }) { value={value} checked={field.value == value} onChange={field.onChange} - {...inputProps} + {...props} /> {radioLabel} </label> @@ -129,12 +188,3 @@ function RadioGroup({ field, label, ...inputProps }) { </div> ); } - -module.exports = { - TextInput, - TextArea, - FileInput, - Checkbox, - Select, - RadioGroup -};
\ No newline at end of file diff --git a/web/source/settings/components/user-logout-card.jsx b/web/source/settings/components/user-logout-card.jsx index de77f0485..9d88642a5 100644 --- a/web/source/settings/components/user-logout-card.jsx +++ b/web/source/settings/components/user-logout-card.jsx @@ -18,15 +18,17 @@ */ const React = require("react"); - -const query = require("../lib/query"); - const Loading = require("./loading"); +const { + useVerifyCredentialsQuery, + useLogoutMutation, +} = require("../lib/query/oauth"); +const { useInstanceV1Query } = require("../lib/query"); module.exports = function UserLogoutCard() { - const { data: profile, isLoading } = query.useVerifyCredentialsQuery(); - const { data: instance } = query.useInstanceQuery(); - const [logoutQuery] = query.useLogoutMutation(); + const { data: profile, isLoading } = useVerifyCredentialsQuery(); + const { data: instance } = useInstanceV1Query(); + const [logoutQuery] = useLogoutMutation(); if (isLoading) { return <Loading />; diff --git a/web/source/settings/index.js b/web/source/settings/index.js index bc3a925c2..57c89be6f 100644 --- a/web/source/settings/index.js +++ b/web/source/settings/index.js @@ -30,6 +30,9 @@ const Loading = require("./components/loading"); const UserLogoutCard = require("./components/user-logout-card"); const { RoleContext } = require("./lib/navigation/util"); +const DomainPerms = require("./admin/domain-permissions").default; +const DomainPermsImportExport = require("./admin/domain-permissions/import-export").default; + require("./style.css"); const { Sidebar, ViewRouter } = createNavigation("/settings", [ @@ -43,10 +46,11 @@ const { Sidebar, ViewRouter } = createNavigation("/settings", [ }, [ Item("Reports", { icon: "fa-flag", wildcard: true }, require("./admin/reports")), Item("Accounts", { icon: "fa-users", wildcard: true }, require("./admin/accounts")), - Menu("Federation", { icon: "fa-hubzilla" }, [ - Item("Federation", { icon: "fa-hubzilla", url: "", wildcard: true }, require("./admin/federation")), - Item("Import/Export", { icon: "fa-floppy-o", wildcard: true }, require("./admin/federation/import-export")), - ]) + Menu("Domain Permissions", { icon: "fa-hubzilla" }, [ + Item("Blocks", { icon: "fa-close", url: "block", wildcard: true }, DomainPerms), + Item("Allows", { icon: "fa-check", url: "allow", wildcard: true }, DomainPerms), + Item("Import/Export", { icon: "fa-floppy-o", url: "import-export", wildcard: true }, DomainPermsImportExport), + ]), ]), Menu("Administration", { url: "admin", diff --git a/web/source/settings/lib/form/bool.jsx b/web/source/settings/lib/form/bool.tsx index 47a4bbd1b..815b17bd3 100644 --- a/web/source/settings/lib/form/bool.jsx +++ b/web/source/settings/lib/form/bool.tsx @@ -17,11 +17,19 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. */ -const React = require("react"); +import { useState } from "react"; +import type { + BoolFormInputHook, + CreateHookNames, + HookOpts, +} from "./types"; const _default = false; -module.exports = function useBoolInput({ name, Name }, { initialValue = _default }) { - const [value, setValue] = React.useState(initialValue); +export default function useBoolInput( + { name, Name }: CreateHookNames, + { initialValue = _default }: HookOpts<boolean> +): BoolFormInputHook { + const [value, setValue] = useState(initialValue); function onChange(e) { setValue(e.target.checked); @@ -41,6 +49,7 @@ module.exports = function useBoolInput({ name, Name }, { initialValue = _default } ], { name, + Name: "", onChange, reset, value, @@ -48,4 +57,4 @@ module.exports = function useBoolInput({ name, Name }, { initialValue = _default hasChanged: () => value != initialValue, _default }); -};
\ No newline at end of file +} diff --git a/web/source/settings/lib/form/check-list.jsx b/web/source/settings/lib/form/check-list.tsx index 2f649dba6..c08e5022f 100644 --- a/web/source/settings/lib/form/check-list.jsx +++ b/web/source/settings/lib/form/check-list.tsx @@ -17,37 +17,58 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. */ -const React = require("react"); -const syncpipe = require("syncpipe"); -const { createSlice } = require("@reduxjs/toolkit"); -const { enableMapSet } = require("immer"); +import { + useReducer, + useRef, + useEffect, + useCallback, + useMemo, +} from "react"; + +import { PayloadAction, createSlice } from "@reduxjs/toolkit"; + +import type { + Checkable, + ChecklistInputHook, + CreateHookNames, + HookOpts, +} from "./types"; + +// https://immerjs.github.io/immer/installation#pick-your-immer-version +import { enableMapSet } from "immer"; +enableMapSet(); + +interface ChecklistState { + entries: { [k: string]: Checkable }, + selectedEntries: Set<string>, +} -enableMapSet(); // for use in reducers +const initialState: ChecklistState = { + entries: {}, + selectedEntries: new Set(), +}; const { reducer, actions } = createSlice({ name: "checklist", - initialState: {}, // not handled by slice itself + 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 - }; + updateAll: (state, { payload: checked }: PayloadAction<boolean>) => { + const selectedEntries = new Set<string>(); + const entries = Object.fromEntries( + Object.values(state.entries).map((entry) => { + if (checked) { + // Cheekily add this to selected + // entries while we're here. + selectedEntries.add(entry.key); + } + + return [entry.key, { ...entry, checked } ]; + }) + ); + + return { entries, selectedEntries }; }, - update: (state, { payload: { key, value } }) => { + update: (state, { payload: { key, value } }: PayloadAction<{key: string, value: Checkable}>) => { if (value.checked !== undefined) { if (value.checked === true) { state.selectedEntries.add(key); @@ -61,7 +82,7 @@ const { reducer, actions } = createSlice({ ...value }; }, - updateMultiple: (state, { payload }) => { + updateMultiple: (state, { payload }: PayloadAction<Array<[key: string, value: Checkable]>>) => { payload.forEach(([key, value]) => { if (value.checked !== undefined) { if (value.checked === true) { @@ -80,43 +101,57 @@ const { reducer, actions } = createSlice({ } }); -function initialState({ entries, uniqueKey, initialValue }) { - const selectedEntries = new Set(); - return { - entries: syncpipe(entries, [ - (_) => _.map((entry) => { - let key = entry[uniqueKey]; - let checked = entry.checked ?? initialValue; - - if (checked) { - selectedEntries.add(key); - } else { - selectedEntries.delete(key); - } +function initialHookState({ + entries, + uniqueKey, + initialValue, +}: { + entries: Checkable[], + uniqueKey: string, + initialValue: boolean, +}): ChecklistState { + const selectedEntries = new Set<string>(); + const mappedEntries = Object.fromEntries( + entries.map((entry) => { + const key = entry[uniqueKey]; + const checked = entry.checked ?? initialValue; + + if (checked) { + selectedEntries.add(key); + } else { + selectedEntries.delete(key); + } + + return [ key, { ...entry, key, checked } ]; + }) + ); - return [ - key, - { - ...entry, - key, - checked - } - ]; - }), - (_) => Object.fromEntries(_) - ]), + return { + entries: mappedEntries, selectedEntries }; } -module.exports = function useCheckListInput({ name }, { entries, uniqueKey = "key", initialValue = false }) { - const [state, dispatch] = React.useReducer(reducer, null, - () => initialState({ entries, uniqueKey, initialValue }) // initial state +const _default: { [k: string]: Checkable } = {}; + +export default function useCheckListInput( + /* eslint-disable no-unused-vars */ + { name, Name }: CreateHookNames, + { + entries = [], + uniqueKey = "key", + initialValue = false, + }: HookOpts<boolean> +): ChecklistInputHook { + const [state, dispatch] = useReducer( + reducer, + initialState, + (_) => initialHookState({ entries, uniqueKey, initialValue }) // initial state ); - const toggleAllRef = React.useRef(null); + const toggleAllRef = useRef<any>(null); - React.useEffect(() => { + useEffect(() => { if (toggleAllRef.current != null) { let some = state.selectedEntries.size > 0; let all = false; @@ -130,22 +165,22 @@ module.exports = function useCheckListInput({ name }, { entries, uniqueKey = "ke // eslint-disable-next-line react-hooks/exhaustive-deps }, [state.selectedEntries]); - const reset = React.useCallback( + const reset = useCallback( () => dispatch(actions.updateAll(initialValue)), [initialValue] ); - const onChange = React.useCallback( + const onChange = useCallback( (key, value) => dispatch(actions.update({ key, value })), [] ); - const updateMultiple = React.useCallback( + const updateMultiple = useCallback( (entries) => dispatch(actions.updateMultiple(entries)), [] ); - return React.useMemo(() => { + return useMemo(() => { function toggleAll(e) { let checked = e.target.checked; if (e.target.indeterminate) { @@ -165,7 +200,10 @@ module.exports = function useCheckListInput({ name }, { entries, uniqueKey = "ke reset, { name } ], { + _default, + hasChanged: () => true, name, + Name: "", value: state.entries, onChange, selectedValues, @@ -178,4 +216,4 @@ module.exports = function useCheckListInput({ name }, { entries, uniqueKey = "ke } }); }, [state, reset, name, onChange, updateMultiple]); -};
\ No newline at end of file +} diff --git a/web/source/settings/lib/form/combo-box.jsx b/web/source/settings/lib/form/combo-box.tsx index 985c262d8..e558d298a 100644 --- a/web/source/settings/lib/form/combo-box.jsx +++ b/web/source/settings/lib/form/combo-box.tsx @@ -17,13 +17,21 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. */ -const React = require("react"); +import { useState } from "react"; -const { useComboboxState } = require("ariakit/combobox"); +import { useComboboxState } from "ariakit/combobox"; +import { + ComboboxFormInputHook, + CreateHookNames, + HookOpts, +} from "./types"; const _default = ""; -module.exports = function useComboBoxInput({ name, Name }, { initialValue = _default }) { - const [isNew, setIsNew] = React.useState(false); +export default function useComboBoxInput( + { name, Name }: CreateHookNames, + { initialValue = _default }: HookOpts<string> +): ComboboxFormInputHook { + const [isNew, setIsNew] = useState(false); const state = useComboboxState({ defaultValue: initialValue, @@ -45,14 +53,15 @@ module.exports = function useComboBoxInput({ name, Name }, { initialValue = _def [`set${Name}IsNew`]: setIsNew } ], { + reset, name, + Name: "", // Will be set by inputHook function. state, value: state.value, - setter: (val) => state.setValue(val), + setter: (val: string) => state.setValue(val), hasChanged: () => state.value != initialValue, isNew, setIsNew, - reset, _default }); -};
\ No newline at end of file +} diff --git a/web/source/settings/lib/form/field-array.jsx b/web/source/settings/lib/form/field-array.tsx index f2d7bc7ce..275bf2b1b 100644 --- a/web/source/settings/lib/form/field-array.jsx +++ b/web/source/settings/lib/form/field-array.tsx @@ -17,12 +17,19 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. */ -const React = require("react"); +import { useRef, useMemo } from "react"; -const getFormMutations = require("./get-form-mutations"); +import getFormMutations from "./get-form-mutations"; -function parseFields(entries, length) { - const fields = []; +import type { + CreateHookNames, + HookOpts, + FieldArrayInputHook, + HookedForm, +} from "./types"; + +function parseFields(entries: HookedForm[], length: number): HookedForm[] { + const fields: HookedForm[] = []; for (let i = 0; i < length; i++) { if (entries[i] != undefined) { @@ -35,23 +42,38 @@ function parseFields(entries, length) { return fields; } -module.exports = function useArrayInput({ name, _Name }, { initialValue, length = 0 }) { - const fields = React.useRef({}); +export default function useArrayInput( + { name }: CreateHookNames, + { + initialValue, + length = 0, + }: HookOpts, +): FieldArrayInputHook { + const _default: HookedForm[] = Array(length); + const fields = useRef<HookedForm[]>(_default); + + const value = useMemo( + () => parseFields(initialValue, length), + [initialValue, length], + ); - const value = React.useMemo(() => parseFields(initialValue, length), [initialValue, length]); + function hasUpdate() { + return Object.values(fields.current).some((fieldSet) => { + const { updatedFields } = getFormMutations(fieldSet, { changedOnly: true }); + return updatedFields.length > 0; + }); + } return { + _default, name, + Name: "", value, ctx: fields.current, maxLength: length, + hasChanged: hasUpdate, selectedValues() { - // if any form field changed, we need to re-send everything - const hasUpdate = Object.values(fields.current).some((fieldSet) => { - const { updatedFields } = getFormMutations(fieldSet, { changedOnly: true }); - return updatedFields.length > 0; - }); - if (hasUpdate) { + if (hasUpdate()) { return Object.values(fields.current).map((fieldSet) => { return getFormMutations(fieldSet, { changedOnly: false }).mutationData; }); @@ -60,4 +82,4 @@ module.exports = function useArrayInput({ name, _Name }, { initialValue, length } } }; -};
\ No newline at end of file +} diff --git a/web/source/settings/lib/form/file.jsx b/web/source/settings/lib/form/file.tsx index a9e96dc97..944d77ae1 100644 --- a/web/source/settings/lib/form/file.jsx +++ b/web/source/settings/lib/form/file.tsx @@ -17,47 +17,67 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. */ -const React = require("react"); -const prettierBytes = require("prettier-bytes"); +import React from "react"; -module.exports = function useFileInput({ name, _Name }, { - withPreview, - maxSize, - initialInfo = "no file selected" -} = {}) { - const [file, setFile] = React.useState(); - const [imageURL, setImageURL] = React.useState(); - const [info, setInfo] = React.useState(); +import { useState } from "react"; +import prettierBytes from "prettier-bytes"; - function onChange(e) { - let file = e.target.files[0]; - setFile(file); +import type { + CreateHookNames, + HookOpts, + FileFormInputHook, +} from "./types"; - URL.revokeObjectURL(imageURL); +const _default = undefined; +export default function useFileInput( + { name }: CreateHookNames, + { + withPreview, + maxSize, + initialInfo = "no file selected" + }: HookOpts<File> +): FileFormInputHook { + const [file, setFile] = useState<File>(); + const [imageURL, setImageURL] = useState<string>(); + const [info, setInfo] = useState<React.JSX.Element>(); - if (file != undefined) { - if (withPreview) { - setImageURL(URL.createObjectURL(file)); - } + function onChange(e: React.ChangeEvent<HTMLInputElement>) { + const files = e.target.files; + if (!files) { + setInfo(undefined); + return; + } - let size = prettierBytes(file.size); - if (maxSize && file.size > maxSize) { - size = <span className="error-text">{size}</span>; - } + let file = files[0]; + setFile(file); - setInfo(<> - {file.name} ({size}) - </>); - } else { - setInfo(); + if (imageURL) { + URL.revokeObjectURL(imageURL); } + + if (withPreview) { + setImageURL(URL.createObjectURL(file)); + } + + let size = prettierBytes(file.size); + if (maxSize && file.size > maxSize) { + size = <span className="error-text">{size}</span>; + } + + setInfo( + <> + {file.name} ({size}) + </> + ); } function reset() { - URL.revokeObjectURL(imageURL); - setImageURL(); - setFile(); - setInfo(); + if (imageURL) { + URL.revokeObjectURL(imageURL); + } + setImageURL(undefined); + setFile(undefined); + setInfo(undefined); } const infoComponent = ( @@ -82,9 +102,11 @@ module.exports = function useFileInput({ name, _Name }, { onChange, reset, name, + Name: "", // Will be set by inputHook function. value: file, previewValue: imageURL, hasChanged: () => file != undefined, - infoComponent + infoComponent, + _default, }); -};
\ No newline at end of file +} diff --git a/web/source/settings/lib/form/form-with-data.jsx b/web/source/settings/lib/form/form-with-data.tsx index ef05c46c0..70a162fb0 100644 --- a/web/source/settings/lib/form/form-with-data.jsx +++ b/web/source/settings/lib/form/form-with-data.tsx @@ -17,14 +17,31 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. */ -const React = require("react"); -const { Error } = require("../../components/error"); +/* eslint-disable no-unused-vars */ -const Loading = require("../../components/loading"); +import React from "react"; -// Wrap Form component inside component that fires the RTK Query call, -// so Form will only be rendered when data is available to generate form-fields for -module.exports = function FormWithData({ dataQuery, DataForm, queryArg, ...formProps }) { +import { Error } from "../../components/error"; +import Loading from "../../components/loading"; +import { NoArg } from "../types/query"; +import { FormWithDataQuery } from "./types"; + +export interface FormWithDataProps { + dataQuery: FormWithDataQuery, + DataForm: ({ data, ...props }) => React.JSX.Element, + queryArg?: any, +} + +/** + * Wrap Form component inside component that fires the RTK Query call, so Form + * will only be rendered when data is available to generate form-fields for. + */ +export default function FormWithData({ dataQuery, DataForm, queryArg, ...props }: FormWithDataProps) { + if (!queryArg) { + queryArg = NoArg; + } + + // Trigger provided query. const { data, isLoading, isError, error } = dataQuery(queryArg); if (isLoading) { @@ -38,6 +55,6 @@ module.exports = function FormWithData({ dataQuery, DataForm, queryArg, ...formP <Error error={error} /> ); } else { - return <DataForm data={data} {...formProps} />; + return <DataForm data={data} {...props} />; } -};
\ No newline at end of file +} diff --git a/web/source/settings/lib/form/get-form-mutations.js b/web/source/settings/lib/form/get-form-mutations.ts index b0ae6e9b0..6e1bfa02d 100644 --- a/web/source/settings/lib/form/get-form-mutations.js +++ b/web/source/settings/lib/form/get-form-mutations.ts @@ -17,29 +17,31 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. */ -const syncpipe = require("syncpipe"); +import { FormInputHook, HookedForm } from "./types"; + +export default function getFormMutations( + form: HookedForm, + { changedOnly }: { changedOnly: boolean }, +) { + const updatedFields: FormInputHook[] = []; + const mutationData: Array<[string, any]> = []; + + Object.values(form).forEach((field) => { + if ("selectedValues" in field) { + // FieldArrayInputHook. + const selected = field.selectedValues(); + if (!changedOnly || selected.length > 0) { + updatedFields.push(field); + mutationData.push([field.name, selected]); + } + } else if (!changedOnly || field.hasChanged()) { + updatedFields.push(field); + mutationData.push([field.name, field.value]); + } + }); -module.exports = function getFormMutations(form, { changedOnly }) { - let updatedFields = []; return { updatedFields, - mutationData: syncpipe(form, [ - (_) => Object.values(_), - (_) => _.map((field) => { - if (field.selectedValues != undefined) { - let selected = field.selectedValues(); - if (!changedOnly || selected.length > 0) { - updatedFields.push(field); - return [field.name, selected]; - } - } else if (!changedOnly || field.hasChanged()) { - updatedFields.push(field); - return [field.name, field.value]; - } - return null; - }), - (_) => _.filter((value) => value != null), - (_) => Object.fromEntries(_) - ]) + mutationData: Object.fromEntries(mutationData), }; -};
\ No newline at end of file +} diff --git a/web/source/settings/lib/form/index.js b/web/source/settings/lib/form/index.js deleted file mode 100644 index 99537ae7f..000000000 --- a/web/source/settings/lib/form/index.js +++ /dev/null @@ -1,83 +0,0 @@ -/* - GoToSocial - Copyright (C) GoToSocial Authors admin@gotosocial.org - SPDX-License-Identifier: AGPL-3.0-or-later - - 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/>. -*/ - -const React = require("react"); -const getByDot = require("get-by-dot").default; - -function capitalizeFirst(str) { - return str.slice(0, 1).toUpperCase + str.slice(1); -} - -function selectorByKey(key) { - if (key.includes("[")) { - // get-by-dot does not support 'nested[deeper][key]' notation, convert to 'nested.deeper.key' - key = key - .replace(/\[/g, ".") // nested.deeper].key] - .replace(/\]/g, ""); // nested.deeper.key - } - - return function selector(obj) { - if (obj == undefined) { - return undefined; - } else { - return getByDot(obj, key); - } - }; -} - -function makeHook(hookFunction) { - return function (name, opts = {}) { - // for dynamically generating attributes like 'setName' - const Name = React.useMemo(() => capitalizeFirst(name), [name]); - - const selector = React.useMemo(() => selectorByKey(name), [name]); - const valueSelector = opts.valueSelector ?? selector; - - opts.initialValue = React.useMemo(() => { - if (opts.source == undefined) { - return opts.defaultValue; - } else { - return valueSelector(opts.source) ?? opts.defaultValue; - } - }, [opts.source, opts.defaultValue, valueSelector]); - - const hook = hookFunction({ name, Name }, opts); - - return Object.assign(hook, { - name, Name, - }); - }; -} - -module.exports = { - useTextInput: makeHook(require("./text")), - useFileInput: makeHook(require("./file")), - useBoolInput: makeHook(require("./bool")), - useRadioInput: makeHook(require("./radio")), - useComboBoxInput: makeHook(require("./combo-box")), - useCheckListInput: makeHook(require("./check-list")), - useFieldArrayInput: makeHook(require("./field-array")), - useValue: function (name, value) { - return { - name, - value, - hasChanged: () => true // always included - }; - } -};
\ No newline at end of file diff --git a/web/source/settings/lib/form/index.ts b/web/source/settings/lib/form/index.ts new file mode 100644 index 000000000..20de33eda --- /dev/null +++ b/web/source/settings/lib/form/index.ts @@ -0,0 +1,114 @@ +/* + GoToSocial + Copyright (C) GoToSocial Authors admin@gotosocial.org + SPDX-License-Identifier: AGPL-3.0-or-later + + 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/>. +*/ + +import { useMemo } from "react"; +import getByDot from "get-by-dot"; + +import text from "./text"; +import file from "./file"; +import bool from "./bool"; +import radio from "./radio"; +import combobox from "./combo-box"; +import checklist from "./check-list"; +import fieldarray from "./field-array"; + +import type { + CreateHook, + FormInputHook, + HookOpts, + TextFormInputHook, + RadioFormInputHook, + FileFormInputHook, + BoolFormInputHook, + ComboboxFormInputHook, + FieldArrayInputHook, + ChecklistInputHook, +} from "./types"; + +function capitalizeFirst(str: string) { + return str.slice(0, 1).toUpperCase + str.slice(1); +} + +function selectorByKey(key: string) { + if (key.includes("[")) { + // get-by-dot does not support 'nested[deeper][key]' notation, convert to 'nested.deeper.key' + key = key + .replace(/\[/g, ".") // nested.deeper].key] + .replace(/\]/g, ""); // nested.deeper.key + } + + return function selector(obj) { + if (obj == undefined) { + return undefined; + } else { + return getByDot(obj, key); + } + }; +} + +/** + * Memoized hook generator function. Take a createHook + * function and use it to return a new FormInputHook function. + * + * @param createHook + * @returns + */ +function inputHook(createHook: CreateHook): (_name: string, _opts: HookOpts) => FormInputHook { + return (name: string, opts?: HookOpts): FormInputHook => { + // for dynamically generating attributes like 'setName' + const Name = useMemo(() => capitalizeFirst(name), [name]); + const selector = useMemo(() => selectorByKey(name), [name]); + const valueSelector = opts?.valueSelector?? selector; + + if (opts) { + opts.initialValue = useMemo(() => { + if (opts.source == undefined) { + return opts.defaultValue; + } else { + return valueSelector(opts.source) ?? opts.defaultValue; + } + }, [opts.source, opts.defaultValue, valueSelector]); + } + + const hook = createHook({ name, Name }, opts ?? {}); + return Object.assign(hook, { name, Name }); + }; +} + +/** + * Simplest form hook type in town. + */ +function value<T>(name: string, initialValue: T) { + return { + _default: initialValue, + name, + Name: "", + value: initialValue, + hasChanged: () => true, // always included + }; +} + +export const useTextInput = inputHook(text) as (_name: string, _opts?: HookOpts<string>) => TextFormInputHook; +export const useFileInput = inputHook(file) as (_name: string, _opts?: HookOpts<File>) => FileFormInputHook; +export const useBoolInput = inputHook(bool) as (_name: string, _opts?: HookOpts<boolean>) => BoolFormInputHook; +export const useRadioInput = inputHook(radio) as (_name: string, _opts?: HookOpts<string>) => RadioFormInputHook; +export const useComboBoxInput = inputHook(combobox) as (_name: string, _opts?: HookOpts<string>) => ComboboxFormInputHook; +export const useCheckListInput = inputHook(checklist) as (_name: string, _opts?: HookOpts<boolean>) => ChecklistInputHook; +export const useFieldArrayInput = inputHook(fieldarray) as (_name: string, _opts?: HookOpts<string>) => FieldArrayInputHook; +export const useValue = value as <T>(_name: string, _initialValue: T) => FormInputHook<T>; diff --git a/web/source/settings/lib/form/radio.jsx b/web/source/settings/lib/form/radio.tsx index 4bb061f4b..164abab9d 100644 --- a/web/source/settings/lib/form/radio.jsx +++ b/web/source/settings/lib/form/radio.tsx @@ -17,11 +17,18 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. */ -const React = require("react"); +import { useState } from "react"; +import { CreateHookNames, HookOpts, RadioFormInputHook } from "./types"; const _default = ""; -module.exports = function useRadioInput({ name, Name }, { initialValue = _default, options }) { - const [value, setValue] = React.useState(initialValue); +export default function useRadioInput( + { name, Name }: CreateHookNames, + { + initialValue = _default, + options = {}, + }: HookOpts<string> +): RadioFormInputHook { + const [value, setValue] = useState(initialValue); function onChange(e) { setValue(e.target.value); @@ -40,13 +47,14 @@ module.exports = function useRadioInput({ name, Name }, { initialValue = _defaul [`set${Name}`]: setValue } ], { - name, onChange, reset, + name, + Name: "", value, setter: setValue, options, hasChanged: () => value != initialValue, _default }); -};
\ No newline at end of file +} diff --git a/web/source/settings/lib/form/submit.js b/web/source/settings/lib/form/submit.js deleted file mode 100644 index ab2945812..000000000 --- a/web/source/settings/lib/form/submit.js +++ /dev/null @@ -1,67 +0,0 @@ -/* - GoToSocial - Copyright (C) GoToSocial Authors admin@gotosocial.org - SPDX-License-Identifier: AGPL-3.0-or-later - - 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/>. -*/ - -const Promise = require("bluebird"); -const React = require("react"); -const getFormMutations = require("./get-form-mutations"); - -module.exports = function useFormSubmit(form, mutationQuery, { changedOnly = true, onFinish } = {}) { - if (!Array.isArray(mutationQuery)) { - throw new ("useFormSubmit: mutationQuery was not an Array. Is a valid useMutation RTK Query provided?"); - } - const [runMutation, result] = mutationQuery; - const usedAction = React.useRef(null); - return [ - function submitForm(e) { - let action; - if (e?.preventDefault) { - e.preventDefault(); - action = e.nativeEvent.submitter.name; - } else { - action = e; - } - - if (action == "") { - action = undefined; - } - usedAction.current = action; - // transform the field definitions into an object with just their values - - const { mutationData, updatedFields } = getFormMutations(form, { changedOnly }); - - if (updatedFields.length == 0) { - return; - } - - mutationData.action = action; - - return Promise.try(() => { - return runMutation(mutationData); - }).then((res) => { - if (onFinish) { - return onFinish(res); - } - }); - }, - { - ...result, - action: usedAction.current - } - ]; -};
\ No newline at end of file diff --git a/web/source/settings/lib/form/submit.ts b/web/source/settings/lib/form/submit.ts new file mode 100644 index 000000000..d5636a587 --- /dev/null +++ b/web/source/settings/lib/form/submit.ts @@ -0,0 +1,140 @@ +/* + GoToSocial + Copyright (C) GoToSocial Authors admin@gotosocial.org + SPDX-License-Identifier: AGPL-3.0-or-later + + 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/>. +*/ + +import getFormMutations from "./get-form-mutations"; + +import { useRef } from "react"; + +import type { + MutationTrigger, + UseMutationStateResult, +} from "@reduxjs/toolkit/dist/query/react/buildHooks"; + +import type { + FormSubmitEvent, + FormSubmitFunction, + FormSubmitResult, + HookedForm, +} from "./types"; + +interface UseFormSubmitOptions { + changedOnly: boolean; + onFinish?: ((_res: any) => void); +} + +/** + * Parse changed values from the hooked form into a request + * body, and submit it using the given mutation trigger. + * + * This function basically wraps RTK Query's submit methods to + * work with our hooked form interface. + * + * An `onFinish` callback function can be provided, which will + * be executed on a **successful** run of the given MutationTrigger, + * with the mutation result passed into it. + * + * If `changedOnly` is false, then **all** fields of the given HookedForm + * will be submitted to the mutation endpoint, not just changed ones. + * + * The returned function and result can be triggered and read + * from just like an RTK Query mutation hook result would be. + * + * See: https://redux-toolkit.js.org/rtk-query/usage/mutations#mutation-hook-behavior + */ +export default function useFormSubmit( + form: HookedForm, + mutationQuery: readonly [MutationTrigger<any>, UseMutationStateResult<any, any>], + opts: UseFormSubmitOptions = { changedOnly: true } +): [ FormSubmitFunction, FormSubmitResult ] { + if (!Array.isArray(mutationQuery)) { + throw "useFormSubmit: mutationQuery was not an Array. Is a valid useMutation RTK Query provided?"; + } + + const { changedOnly, onFinish } = opts; + const [runMutation, mutationResult] = mutationQuery; + const usedAction = useRef<FormSubmitEvent>(undefined); + + const submitForm = async(e: FormSubmitEvent) => { + let action: FormSubmitEvent; + + if (typeof e === "string") { + if (e !== "") { + // String action name was provided. + action = e; + } else { + // Empty string action name was provided. + action = undefined; + } + } else if (e) { + // Submit event action was provided. + e.preventDefault(); + if (e.nativeEvent.submitter) { + // We want the name of the element that was invoked to submit this form, + // which will be something that extends HTMLElement, though we don't know + // what at this point. + // + // See: https://developer.mozilla.org/en-US/docs/Web/API/SubmitEvent/submitter + action = (e.nativeEvent.submitter as Object as { name: string }).name; + } else { + // No submitter defined. Fall back + // to just use the FormSubmitEvent. + action = e; + } + } else { + // Void or null or something + // else was provided. + action = undefined; + } + + usedAction.current = action; + + // Transform the hooked form into an object. + const { + mutationData, + updatedFields, + } = getFormMutations(form, { changedOnly }); + + // If there were no updated fields according to + // the form parsing then there's nothing for us + // to do, since remote and desired state match. + if (updatedFields.length == 0) { + return; + } + + mutationData.action = action; + + try { + const res = await runMutation(mutationData); + if (onFinish) { + onFinish(res); + } + } catch (e) { + // eslint-disable-next-line no-console + console.error(`caught error running mutation: ${e}`); + } + }; + + return [ + submitForm, + { + ...mutationResult, + action: usedAction.current + } + ]; +} diff --git a/web/source/settings/lib/form/text.jsx b/web/source/settings/lib/form/text.tsx index f9c096ac8..c0b9b93c6 100644 --- a/web/source/settings/lib/form/text.jsx +++ b/web/source/settings/lib/form/text.tsx @@ -17,26 +17,40 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. */ -const React = require("react"); +import React, { + useState, + useRef, + useTransition, + useEffect, +} from "react"; + +import type { + CreateHookNames, + HookOpts, + TextFormInputHook, +} from "./types"; const _default = ""; -module.exports = function useTextInput({ name, Name }, { - initialValue = _default, - dontReset = false, - validator, - showValidation = true, - initValidation -} = {}) { - const [text, setText] = React.useState(initialValue); - const textRef = React.useRef(null); +export default function useTextInput( + { name, Name }: CreateHookNames, + { + initialValue = _default, + dontReset = false, + validator, + showValidation = true, + initValidation + }: HookOpts<string> +): TextFormInputHook { + const [text, setText] = useState(initialValue); + const textRef = useRef<HTMLInputElement>(null); - const [validation, setValidation] = React.useState(initValidation ?? ""); - const [_isValidating, startValidation] = React.useTransition(); - let valid = validation == ""; + const [validation, setValidation] = useState(initValidation ?? ""); + const [_isValidating, startValidation] = useTransition(); + const valid = validation == ""; - function onChange(e) { - let input = e.target.value; + function onChange(e: React.ChangeEvent<HTMLInputElement>) { + const input = e.target.value; setText(input); if (validator) { @@ -52,7 +66,7 @@ module.exports = function useTextInput({ name, Name }, { } } - React.useEffect(() => { + useEffect(() => { if (validator && textRef.current) { if (showValidation) { textRef.current.setCustomValidity(validation); @@ -76,12 +90,13 @@ module.exports = function useTextInput({ name, Name }, { onChange, reset, name, + Name: "", // Will be set by inputHook function. value: text, ref: textRef, setter: setText, valid, - validate: () => setValidation(validator(text)), + validate: () => setValidation(validator ? validator(text): ""), hasChanged: () => text != initialValue, _default }); -};
\ No newline at end of file +} diff --git a/web/source/settings/lib/form/types.ts b/web/source/settings/lib/form/types.ts new file mode 100644 index 000000000..45db9e0b8 --- /dev/null +++ b/web/source/settings/lib/form/types.ts @@ -0,0 +1,264 @@ +/* + GoToSocial + Copyright (C) GoToSocial Authors admin@gotosocial.org + SPDX-License-Identifier: AGPL-3.0-or-later + + 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/>. +*/ + +/* eslint-disable no-unused-vars */ + +import { ComboboxState } from "ariakit"; +import React from "react"; + +import { + ChangeEventHandler, + Dispatch, + RefObject, + SetStateAction, + SyntheticEvent, +} from "react"; + +export interface CreateHookNames { + name: string; + Name: string; +} + +export interface HookOpts<T = any> { + initialValue?: T, + defaultValue?: T, + + dontReset?: boolean, + validator?, + showValidation?: boolean, + initValidation?: string, + length?: number; + options?: { [_: string]: string }, + withPreview?: boolean, + maxSize?, + initialInfo?: string; + valueSelector?: Function, + source?, + + // checklist input types + entries?: any[]; + uniqueKey?: string; +} + +export type CreateHook = ( + name: CreateHookNames, + opts: HookOpts, +) => FormInputHook; + +export interface FormInputHook<T = any> { + /** + * Name of this FormInputHook, as provided + * in the UseFormInputHook options. + */ + name: string; + + /** + * `name` with first letter capitalized. + */ + Name: string; + + /** + * Current value of this FormInputHook. + */ + value?: T; + + /** + * Default value of this FormInputHook. + */ + _default: T; + + /** + * Return true if the values of this hook is considered + * to have been changed from the default / initial value. + */ + hasChanged: () => boolean; +} + +interface _withReset { + reset: () => void; +} + +interface _withOnChange { + onChange: ChangeEventHandler; +} + +interface _withSetter<T> { + setter: Dispatch<SetStateAction<T>>; +} + +interface _withValidate { + valid: boolean; + validate: () => void; +} + +interface _withRef { + ref: RefObject<HTMLElement>; +} + +interface _withFile { + previewValue?: string; + infoComponent: React.JSX.Element; +} + +interface _withComboboxState { + state: ComboboxState; +} + +interface _withNew { + isNew: boolean; + setIsNew: Dispatch<SetStateAction<boolean>>; +} + +interface _withSelectedValues { + selectedValues: () => { + [_: string]: any; + }[] +} + +interface _withCtx { + ctx +} + +interface _withMaxLength { + maxLength: number; +} + +interface _withOptions { + options: { [_: string]: string }; +} + +interface _withToggleAll { + toggleAll: _withRef & _withOnChange +} + +interface _withSomeSelected { + someSelected: boolean; +} + +interface _withUpdateMultiple { + updateMultiple: (_entries: any) => void; +} + +export interface TextFormInputHook extends FormInputHook<string>, + _withSetter<string>, + _withOnChange, + _withReset, + _withValidate, + _withRef {} + +export interface RadioFormInputHook extends FormInputHook<string>, + _withSetter<string>, + _withOnChange, + _withOptions, + _withReset {} + +export interface FileFormInputHook extends FormInputHook<File | undefined>, + _withOnChange, + _withReset, + Partial<_withRef>, + _withFile {} + +export interface BoolFormInputHook extends FormInputHook<boolean>, + _withSetter<boolean>, + _withOnChange, + _withReset {} + +export interface ComboboxFormInputHook extends FormInputHook<string>, + _withSetter<string>, + _withComboboxState, + _withNew, + _withReset {} + +export interface FieldArrayInputHook extends FormInputHook<HookedForm[]>, + _withSelectedValues, + _withMaxLength, + _withCtx {} + +export interface Checkable { + key: string; + checked?: boolean; +} + +export interface ChecklistInputHook<T = Checkable> extends FormInputHook<{[k: string]: T}>, + _withReset, + _withToggleAll, + _withSelectedValues, + _withSomeSelected, + _withUpdateMultiple { + // Uses its own funky onChange handler. + onChange: (key: any, value: any) => void + } + +export type AnyFormInputHook = + FormInputHook | + TextFormInputHook | + RadioFormInputHook | + FileFormInputHook | + BoolFormInputHook | + ComboboxFormInputHook | + FieldArrayInputHook | + ChecklistInputHook; + +export interface HookedForm { + [_: string]: AnyFormInputHook +} + +/** + * Parameters for FormSubmitFunction. + */ +export type FormSubmitEvent = (string | SyntheticEvent<HTMLFormElement, Partial<SubmitEvent>> | undefined | void) + + +/** + * Shadows "trigger" function for useMutation, but can also + * be passed to onSubmit property of forms as a handler. + * + * See: https://redux-toolkit.js.org/rtk-query/usage/mutations#mutation-hook-behavior + */ +export type FormSubmitFunction = ((_e: FormSubmitEvent) => void) + +/** + * Shadows redux mutation hook return values. + * + * See: https://redux-toolkit.js.org/rtk-query/usage/mutations#frequently-used-mutation-hook-return-values + */ +export interface FormSubmitResult { + /** + * Action used to submit the form, if any. + */ + action: FormSubmitEvent; + data: any; + error: any; + isLoading: boolean; + isSuccess: boolean; + isError: boolean; + reset: () => void; +} + +/** + * Shadows redux query hook return values. + * + * See: https://redux-toolkit.js.org/rtk-query/usage/queries#frequently-used-query-hook-return-values + */ +export type FormWithDataQuery = (_queryArg: any) => { + data?: any; + isLoading: boolean; + isError: boolean; + error?: any; +} diff --git a/web/source/settings/lib/query/admin/custom-emoji.js b/web/source/settings/lib/query/admin/custom-emoji.js deleted file mode 100644 index 6e7c772a2..000000000 --- a/web/source/settings/lib/query/admin/custom-emoji.js +++ /dev/null @@ -1,194 +0,0 @@ -/* - GoToSocial - Copyright (C) GoToSocial Authors admin@gotosocial.org - SPDX-License-Identifier: AGPL-3.0-or-later - - 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/>. -*/ - -const Promise = require("bluebird"); - -const { unwrapRes } = require("../lib"); - -module.exports = (build) => ({ - listEmoji: build.query({ - query: (params = {}) => ({ - url: "/api/v1/admin/custom_emojis", - params: { - limit: 0, - ...params - } - }), - providesTags: (res) => - res - ? [...res.map((emoji) => ({ type: "Emoji", id: emoji.id })), { type: "Emoji", id: "LIST" }] - : [{ type: "Emoji", id: "LIST" }] - }), - - getEmoji: build.query({ - query: (id) => ({ - url: `/api/v1/admin/custom_emojis/${id}` - }), - providesTags: (res, error, id) => [{ type: "Emoji", id }] - }), - - addEmoji: build.mutation({ - query: (form) => { - return { - method: "POST", - url: `/api/v1/admin/custom_emojis`, - asForm: true, - body: form, - discardEmpty: true - }; - }, - invalidatesTags: (res) => - res - ? [{ type: "Emoji", id: "LIST" }, { type: "Emoji", id: res.id }] - : [{ type: "Emoji", id: "LIST" }] - }), - - editEmoji: build.mutation({ - query: ({ id, ...patch }) => { - return { - method: "PATCH", - url: `/api/v1/admin/custom_emojis/${id}`, - asForm: true, - body: { - type: "modify", - ...patch - } - }; - }, - invalidatesTags: (res) => - res - ? [{ type: "Emoji", id: "LIST" }, { type: "Emoji", id: res.id }] - : [{ type: "Emoji", id: "LIST" }] - }), - - deleteEmoji: build.mutation({ - query: (id) => ({ - method: "DELETE", - url: `/api/v1/admin/custom_emojis/${id}` - }), - invalidatesTags: (res, error, id) => [{ type: "Emoji", id }] - }), - - searchStatusForEmoji: build.mutation({ - queryFn: (url, api, _extraOpts, baseQuery) => { - return Promise.try(() => { - return baseQuery({ - url: `/api/v2/search?q=${encodeURIComponent(url)}&resolve=true&limit=1` - }).then(unwrapRes); - }).then((searchRes) => { - return emojiFromSearchResult(searchRes); - }).then(({ type, domain, list }) => { - const state = api.getState(); - if (domain == new URL(state.oauth.instance).host) { - throw "LOCAL_INSTANCE"; - } - - // search for every mentioned emoji with the admin api to get their ID - return Promise.map(list, (emoji) => { - return baseQuery({ - url: `/api/v1/admin/custom_emojis`, - params: { - filter: `domain:${domain},shortcode:${emoji.shortcode}`, - limit: 1 - } - }).then((unwrapRes)).then((list) => list[0]); - }, { concurrency: 5 }).then((listWithIDs) => { - return { - data: { - type, - domain, - list: listWithIDs - } - }; - }); - }).catch((e) => { - return { error: e }; - }); - } - }), - - patchRemoteEmojis: build.mutation({ - queryFn: ({ action, ...formData }, _api, _extraOpts, baseQuery) => { - const data = []; - const errors = []; - - return Promise.each(formData.selectedEmoji, (emoji) => { - return Promise.try(() => { - let body = { - type: action - }; - - if (action == "copy") { - body.shortcode = emoji.shortcode; - if (formData.category.trim().length != 0) { - body.category = formData.category; - } - } - - return baseQuery({ - method: "PATCH", - url: `/api/v1/admin/custom_emojis/${emoji.id}`, - asForm: true, - body: body - }).then(unwrapRes); - }).then((res) => { - data.push([emoji.id, res]); - }).catch((e) => { - let msg = e.message ?? e; - if (e.data.error) { - msg = e.data.error; - } - errors.push([emoji.shortcode, msg]); - }); - }).then(() => { - if (errors.length == 0) { - return { data }; - } else { - return { - error: errors - }; - } - }); - }, - invalidatesTags: () => [{ type: "Emoji", id: "LIST" }] - }) -}); - -function emojiFromSearchResult(searchRes) { - /* Parses the search response, prioritizing a toot result, - and returns referenced custom emoji - */ - let type; - - if (searchRes.statuses.length > 0) { - type = "statuses"; - } else if (searchRes.accounts.length > 0) { - type = "accounts"; - } else { - throw "NONE_FOUND"; - } - - let data = searchRes[type][0]; - - return { - type, - domain: (new URL(data.url)).host, // to get WEB_DOMAIN, see https://github.com/superseriousbusiness/gotosocial/issues/1225 - list: data.emojis - }; -}
\ No newline at end of file diff --git a/web/source/settings/lib/query/admin/custom-emoji/index.ts b/web/source/settings/lib/query/admin/custom-emoji/index.ts new file mode 100644 index 000000000..d624b0580 --- /dev/null +++ b/web/source/settings/lib/query/admin/custom-emoji/index.ts @@ -0,0 +1,307 @@ +/* + GoToSocial + Copyright (C) GoToSocial Authors admin@gotosocial.org + SPDX-License-Identifier: AGPL-3.0-or-later + + 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/>. +*/ + +import { gtsApi } from "../../gts-api"; +import { FetchBaseQueryError } from "@reduxjs/toolkit/query"; +import { RootState } from "../../../../redux/store"; + +import type { CustomEmoji, EmojisFromItem, ListEmojiParams } from "../../../types/custom-emoji"; + +/** + * Parses the search response, prioritizing a status + * result, and returns any referenced custom emoji. + * + * Due to current API constraints, the returned emojis + * will not have their ID property set, so further + * processing is required to retrieve the IDs. + * + * @param searchRes + * @returns + */ +function emojisFromSearchResult(searchRes): EmojisFromItem { + // We don't know in advance whether a searched URL + // is the URL for a status, or the URL for an account, + // but we can derive this by looking at which search + // result field actually has entries in it (if any). + let type: "statuses" | "accounts"; + if (searchRes.statuses.length > 0) { + // We had status results, + // so this was a status URL. + type = "statuses"; + } else if (searchRes.accounts.length > 0) { + // We had account results, + // so this was an account URL. + type = "accounts"; + } else { + // Nada, zilch, we can't do + // anything with this. + throw "NONE_FOUND"; + } + + // Narrow type to discard all the other + // data on the result that we don't need. + const data: { + url: string; + emojis: CustomEmoji[]; + } = searchRes[type][0]; + + return { + type, + // Workaround to get host rather than account domain. + // See https://github.com/superseriousbusiness/gotosocial/issues/1225. + domain: (new URL(data.url)).host, + list: data.emojis, + }; +} + +const extended = gtsApi.injectEndpoints({ + endpoints: (build) => ({ + listEmoji: build.query<CustomEmoji[], ListEmojiParams | void>({ + query: (params = {}) => ({ + url: "/api/v1/admin/custom_emojis", + params: { + limit: 0, + ...params + } + }), + providesTags: (res, _error, _arg) => + res + ? [ + ...res.map((emoji) => ({ type: "Emoji" as const, id: emoji.id })), + { type: "Emoji", id: "LIST" } + ] + : [{ type: "Emoji", id: "LIST" }] + }), + + getEmoji: build.query<CustomEmoji, string>({ + query: (id) => ({ + url: `/api/v1/admin/custom_emojis/${id}` + }), + providesTags: (_res, _error, id) => [{ type: "Emoji", id }] + }), + + addEmoji: build.mutation<CustomEmoji, Object>({ + query: (form) => { + return { + method: "POST", + url: `/api/v1/admin/custom_emojis`, + asForm: true, + body: form, + discardEmpty: true + }; + }, + invalidatesTags: (res) => + res + ? [{ type: "Emoji", id: "LIST" }, { type: "Emoji", id: res.id }] + : [{ type: "Emoji", id: "LIST" }] + }), + + editEmoji: build.mutation<CustomEmoji, any>({ + query: ({ id, ...patch }) => { + return { + method: "PATCH", + url: `/api/v1/admin/custom_emojis/${id}`, + asForm: true, + body: { + type: "modify", + ...patch + } + }; + }, + invalidatesTags: (res) => + res + ? [{ type: "Emoji", id: "LIST" }, { type: "Emoji", id: res.id }] + : [{ type: "Emoji", id: "LIST" }] + }), + + deleteEmoji: build.mutation<any, string>({ + query: (id) => ({ + method: "DELETE", + url: `/api/v1/admin/custom_emojis/${id}` + }), + invalidatesTags: (_res, _error, id) => [{ type: "Emoji", id }] + }), + + searchItemForEmoji: build.mutation<EmojisFromItem, string>({ + async queryFn(url, api, _extraOpts, fetchWithBQ) { + const state = api.getState() as RootState; + const oauthState = state.oauth; + + // First search for given url. + const searchRes = await fetchWithBQ({ + url: `/api/v2/search?q=${encodeURIComponent(url)}&resolve=true&limit=1` + }); + if (searchRes.error) { + return { error: searchRes.error as FetchBaseQueryError }; + } + + // Parse initial results of search. + // These emojis will not have IDs set. + const { + type, + domain, + list: withoutIDs, + } = emojisFromSearchResult(searchRes.data); + + // Ensure emojis domain is not OUR domain. If it + // is, we already have the emojis by definition. + if (oauthState.instanceUrl !== undefined) { + if (domain == new URL(oauthState.instanceUrl).host) { + throw "LOCAL_INSTANCE"; + } + } + + // Search for each listed emoji with the admin + // api to get the version that includes an ID. + const withIDs: CustomEmoji[] = []; + const errors: FetchBaseQueryError[] = []; + + withoutIDs.forEach(async(emoji) => { + // Request admin view of this emoji. + const emojiRes = await fetchWithBQ({ + url: `/api/v1/admin/custom_emojis`, + params: { + filter: `domain:${domain},shortcode:${emoji.shortcode}`, + limit: 1 + } + }); + if (emojiRes.error) { + errors.push(emojiRes.error); + } else { + // Got it! + withIDs.push(emojiRes.data as CustomEmoji); + } + }); + + if (errors.length !== 0) { + return { + error: { + status: 400, + statusText: 'Bad Request', + data: {"error":`One or more errors fetching custom emojis: ${errors}`}, + }, + }; + } + + // Return our ID'd + // emojis list. + return { + data: { + type, + domain, + list: withIDs, + } + }; + } + }), + + patchRemoteEmojis: build.mutation({ + async queryFn({ action, ...formData }, _api, _extraOpts, fetchWithBQ) { + const data: CustomEmoji[] = []; + const errors: FetchBaseQueryError[] = []; + + formData.selectEmoji.forEach(async(emoji: CustomEmoji) => { + let body = { + type: action, + shortcode: "", + category: "", + }; + + if (action == "copy") { + body.shortcode = emoji.shortcode; + if (formData.category.trim().length != 0) { + body.category = formData.category; + } + } + + const emojiRes = await fetchWithBQ({ + method: "PATCH", + url: `/api/v1/admin/custom_emojis/${emoji.id}`, + asForm: true, + body: body + }); + if (emojiRes.error) { + errors.push(emojiRes.error); + } else { + // Got it! + data.push(emojiRes.data as CustomEmoji); + } + }); + + if (errors.length !== 0) { + return { + error: { + status: 400, + statusText: 'Bad Request', + data: {"error":`One or more errors patching custom emojis: ${errors}`}, + }, + }; + } + + return { data }; + }, + invalidatesTags: () => [{ type: "Emoji", id: "LIST" }] + }) + }) +}); + +/** + * List all custom emojis uploaded on our local instance. + */ +const useListEmojiQuery = extended.useListEmojiQuery; + +/** + * Get a single custom emoji uploaded on our local instance, by its ID. + */ +const useGetEmojiQuery = extended.useGetEmojiQuery; + +/** + * Add a new custom emoji by uploading it to our local instance. + */ +const useAddEmojiMutation = extended.useAddEmojiMutation; + +/** + * Edit an existing custom emoji that's already been uploaded to our local instance. + */ +const useEditEmojiMutation = extended.useEditEmojiMutation; + +/** + * Delete a single custom emoji from our local instance using its id. + */ +const useDeleteEmojiMutation = extended.useDeleteEmojiMutation; + +/** + * "Steal this look" function for selecting remote emoji from a status or account. + */ +const useSearchItemForEmojiMutation = extended.useSearchItemForEmojiMutation; + +/** + * Update/patch a bunch of remote emojis. + */ +const usePatchRemoteEmojisMutation = extended.usePatchRemoteEmojisMutation; + +export { + useListEmojiQuery, + useGetEmojiQuery, + useAddEmojiMutation, + useEditEmojiMutation, + useDeleteEmojiMutation, + useSearchItemForEmojiMutation, + usePatchRemoteEmojisMutation, +}; diff --git a/web/source/settings/lib/query/admin/domain-permissions/export.ts b/web/source/settings/lib/query/admin/domain-permissions/export.ts new file mode 100644 index 000000000..b6ef560f4 --- /dev/null +++ b/web/source/settings/lib/query/admin/domain-permissions/export.ts @@ -0,0 +1,155 @@ +/* + GoToSocial + Copyright (C) GoToSocial Authors admin@gotosocial.org + SPDX-License-Identifier: AGPL-3.0-or-later + + 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/>. +*/ + +import fileDownload from "js-file-download"; +import { unparse as csvUnparse } from "papaparse"; + +import { gtsApi } from "../../gts-api"; +import { RootState } from "../../../../redux/store"; +import { FetchBaseQueryError } from "@reduxjs/toolkit/query"; +import { DomainPerm, ExportDomainPermsParams } from "../../../types/domain-permission"; + +interface _exportProcess { + transformEntry: (_entry: DomainPerm) => any; + stringify: (_list: any[]) => string; + extension: string; + mime: string; +} + +/** + * Derive process functions and metadata + * from provided export request form. + * + * @param formData + * @returns + */ +function exportProcess(formData: ExportDomainPermsParams): _exportProcess { + if (formData.exportType == "json") { + return { + transformEntry: (entry) => ({ + domain: entry.domain, + public_comment: entry.public_comment, + obfuscate: entry.obfuscate + }), + stringify: (list) => JSON.stringify(list), + extension: ".json", + mime: "application/json" + }; + } + + if (formData.exportType == "csv") { + return { + transformEntry: (entry) => [ + entry.domain, // #domain + "suspend", // #severity + false, // #reject_media + false, // #reject_reports + entry.public_comment, // #public_comment + entry.obfuscate ?? false // #obfuscate + ], + stringify: (list) => csvUnparse({ + fields: [ + "#domain", + "#severity", + "#reject_media", + "#reject_reports", + "#public_comment", + "#obfuscate", + ], + data: list + }), + extension: ".csv", + mime: "text/csv" + }; + } + + // Fall back to plain text export. + return { + transformEntry: (entry) => entry.domain, + stringify: (list) => list.join("\n"), + extension: ".txt", + mime: "text/plain" + }; +} + +const extended = gtsApi.injectEndpoints({ + endpoints: (build) => ({ + exportDomainList: build.mutation<string | null, ExportDomainPermsParams>({ + async queryFn(formData, api, _extraOpts, fetchWithBQ) { + // Fetch domain perms from relevant endpoint. + // We could have used 'useDomainBlocksQuery' + // or 'useDomainAllowsQuery' for this, but + // we want the untransformed array version. + const permsRes = await fetchWithBQ({ url: `/api/v1/admin/domain_${formData.permType}s` }); + if (permsRes.error) { + return { error: permsRes.error as FetchBaseQueryError }; + } + + // Process perms into desired export format. + const process = exportProcess(formData); + const transformed = (permsRes.data as DomainPerm[]).map(process.transformEntry); + const exportAsString = process.stringify(transformed); + + if (formData.action == "export") { + // Data will just be exported + // to the domains text field. + return { data: exportAsString }; + } + + // File export has been requested. + // Parse filename to something like: + // `example.org-blocklist-2023-10-09.json`. + const state = api.getState() as RootState; + const instanceUrl = state.oauth.instanceUrl?? "unknown"; + const domain = new URL(instanceUrl).host; + const date = new Date(); + const filename = [ + domain, + "blocklist", + date.getFullYear(), + (date.getMonth() + 1).toString().padStart(2, "0"), + date.getDate().toString().padStart(2, "0"), + ].join("-"); + + fileDownload( + exportAsString, + filename + process.extension, + process.mime + ); + + // js-file-download handles the + // nitty gritty for us, so we can + // just return null data. + return { data: null }; + } + }), + }) +}); + +/** + * Makes a GET to `/api/v1/admin/domain_{perm_type}s` + * and exports the result in the requested format. + * + * Return type will be string if `action` is "export", + * else it will be null, since the file downloader handles + * the rest of the request then. + */ +const useExportDomainListMutation = extended.useExportDomainListMutation; + +export { useExportDomainListMutation }; diff --git a/web/source/settings/lib/query/admin/domain-permissions/get.ts b/web/source/settings/lib/query/admin/domain-permissions/get.ts new file mode 100644 index 000000000..3e27742d4 --- /dev/null +++ b/web/source/settings/lib/query/admin/domain-permissions/get.ts @@ -0,0 +1,56 @@ +/* + GoToSocial + Copyright (C) GoToSocial Authors admin@gotosocial.org + SPDX-License-Identifier: AGPL-3.0-or-later + + 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/>. +*/ + +import { gtsApi } from "../../gts-api"; + +import type { DomainPerm, MappedDomainPerms } from "../../../types/domain-permission"; +import { listToKeyedObject } from "../../transforms"; + +const extended = gtsApi.injectEndpoints({ + endpoints: (build) => ({ + domainBlocks: build.query<MappedDomainPerms, void>({ + query: () => ({ + url: `/api/v1/admin/domain_blocks` + }), + transformResponse: listToKeyedObject<DomainPerm>("domain"), + }), + + domainAllows: build.query<MappedDomainPerms, void>({ + query: () => ({ + url: `/api/v1/admin/domain_allows` + }), + transformResponse: listToKeyedObject<DomainPerm>("domain"), + }), + }), +}); + +/** + * Get admin view of all explicitly blocked domains. + */ +const useDomainBlocksQuery = extended.useDomainBlocksQuery; + +/** + * Get admin view of all explicitly allowed domains. + */ +const useDomainAllowsQuery = extended.useDomainAllowsQuery; + +export { + useDomainBlocksQuery, + useDomainAllowsQuery, +}; diff --git a/web/source/settings/lib/query/admin/domain-permissions/import.ts b/web/source/settings/lib/query/admin/domain-permissions/import.ts new file mode 100644 index 000000000..dde488625 --- /dev/null +++ b/web/source/settings/lib/query/admin/domain-permissions/import.ts @@ -0,0 +1,140 @@ +/* + GoToSocial + Copyright (C) GoToSocial Authors admin@gotosocial.org + SPDX-License-Identifier: AGPL-3.0-or-later + + 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/>. +*/ + +import { replaceCacheOnMutation } from "../../query-modifiers"; +import { gtsApi } from "../../gts-api"; + +import { + type DomainPerm, + type ImportDomainPermsParams, + type MappedDomainPerms, + isDomainPermInternalKey, +} from "../../../types/domain-permission"; +import { listToKeyedObject } from "../../transforms"; + +/** + * Builds up a map function that can be applied to a + * list of DomainPermission entries in order to normalize + * them before submission to the API. + * @param formData + * @returns + */ +function importEntriesProcessor(formData: ImportDomainPermsParams): (_entry: DomainPerm) => DomainPerm { + let processingFuncs: { (_entry: DomainPerm): void; }[] = []; + + // Override each obfuscate entry if necessary. + if (formData.obfuscate !== undefined) { + const obfuscateEntry = (entry: DomainPerm) => { + entry.obfuscate = formData.obfuscate; + }; + processingFuncs.push(obfuscateEntry); + } + + // Check whether we need to append or replace + // private_comment and public_comment. + ["private_comment","public_comment"].forEach((commentType) => { + let text = formData.commentType?.trim(); + if (!text) { + return; + } + + switch(formData[`${commentType}_behavior`]) { + case "append": + const appendComment = (entry: DomainPerm) => { + if (entry.commentType == undefined) { + entry.commentType = text; + } else { + entry.commentType = [entry.commentType, text].join("\n"); + } + }; + + processingFuncs.push(appendComment); + break; + case "replace": + const replaceComment = (entry: DomainPerm) => { + entry.commentType = text; + }; + + processingFuncs.push(replaceComment); + break; + } + }); + + return function process(entry) { + // Call all the assembled processing functions. + processingFuncs.forEach((f) => f(entry)); + + // Unset all internal processing keys + // and any undefined keys on this entry. + Object.entries(entry).forEach(([key, val]: [keyof DomainPerm, any]) => { + if (val == undefined || isDomainPermInternalKey(key)) { + delete entry[key]; + } + }); + + return entry; + }; +} + +const extended = gtsApi.injectEndpoints({ + endpoints: (build) => ({ + importDomainPerms: build.mutation<MappedDomainPerms, ImportDomainPermsParams>({ + query: (formData) => { + // Add/replace comments, remove internal keys. + const process = importEntriesProcessor(formData); + const domains = formData.domains.map(process); + + return { + method: "POST", + url: `/api/v1/admin/domain_${formData.permType}s?import=true`, + asForm: true, + discardEmpty: true, + body: { + import: true, + domains: new Blob( + [JSON.stringify(domains)], + { type: "application/json" }, + ), + } + }; + }, + transformResponse: listToKeyedObject<DomainPerm>("domain"), + ...replaceCacheOnMutation((formData: ImportDomainPermsParams) => { + // Query names for blocks and allows are like + // `domainBlocks` and `domainAllows`, so we need + // to convert `block` -> `Block` or `allow` -> `Allow` + // to do proper cache invalidation. + const permType = + formData.permType.charAt(0).toUpperCase() + + formData.permType.slice(1); + return `domain${permType}s`; + }), + }) + }) +}); + +/** + * POST domain permissions to /api/v1/admin/domain_{permType}s. + * Returns the newly created permissions. + */ +const useImportDomainPermsMutation = extended.useImportDomainPermsMutation; + +export { + useImportDomainPermsMutation, +}; diff --git a/web/source/settings/lib/query/admin/domain-permissions/process.ts b/web/source/settings/lib/query/admin/domain-permissions/process.ts new file mode 100644 index 000000000..017d02bb4 --- /dev/null +++ b/web/source/settings/lib/query/admin/domain-permissions/process.ts @@ -0,0 +1,163 @@ +/* + GoToSocial + Copyright (C) GoToSocial Authors admin@gotosocial.org + SPDX-License-Identifier: AGPL-3.0-or-later + + 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/>. +*/ + +import { + ParseConfig as CSVParseConfig, + parse as csvParse +} from "papaparse"; +import { nanoid } from "nanoid"; + +import { isValidDomainPermission, hasBetterScope } from "../../../util/domain-permission"; +import { gtsApi } from "../../gts-api"; + +import { + isDomainPerms, + type DomainPerm, +} from "../../../types/domain-permission"; + +/** + * Parse the given string of domain permissions and return it as an array. + * Accepts input as a JSON array string, a CSV, or newline-separated domain names. + * Will throw an error if input is invalid. + * @param list + * @returns + * @throws + */ +function parseDomainList(list: string): DomainPerm[] { + if (list.startsWith("[")) { + // Assume JSON array. + const data = JSON.parse(list); + if (!isDomainPerms(data)) { + throw "parsed JSON was not array of DomainPermission"; + } + + return data; + } else if (list.startsWith("#domain") || list.startsWith("domain,severity")) { + // Assume Mastodon-style CSV. + const csvParseCfg: CSVParseConfig = { + header: true, + // Remove leading '#' if present. + transformHeader: (header) => header.startsWith("#") ? header.slice(1) : header, + skipEmptyLines: true, + dynamicTyping: true + }; + + const { data, errors } = csvParse(list, csvParseCfg); + if (errors.length > 0) { + let error = ""; + errors.forEach((err) => { + error += `${err.message} (line ${err.row})`; + }); + throw error; + } + + if (!isDomainPerms(data)) { + throw "parsed CSV was not array of DomainPermission"; + } + + return data; + } else { + // Fallback: assume newline-separated + // list of simple domain strings. + const data: DomainPerm[] = []; + list.split("\n").forEach((line) => { + let domain = line.trim(); + let valid = true; + + if (domain.startsWith("http")) { + try { + domain = new URL(domain).hostname; + } catch (e) { + valid = false; + } + } + + if (domain.length > 0) { + data.push({ domain, valid }); + } + }); + + return data; + } +} + +function deduplicateDomainList(list: DomainPerm[]): DomainPerm[] { + let domains = new Set(); + return list.filter((entry) => { + if (domains.has(entry.domain)) { + return false; + } else { + domains.add(entry.domain); + return true; + } + }); +} + +function validateDomainList(list: DomainPerm[]) { + list.forEach((entry) => { + if (entry.domain.startsWith("*.")) { + // A domain permission always includes + // all subdomains, wildcard is meaningless here + entry.domain = entry.domain.slice(2); + } + + entry.valid = (entry.valid !== false) && isValidDomainPermission(entry.domain); + if (entry.valid) { + entry.suggest = hasBetterScope(entry.domain); + } + entry.checked = entry.valid; + }); + + return list; +} + +const extended = gtsApi.injectEndpoints({ + endpoints: (build) => ({ + processDomainPermissions: build.mutation<DomainPerm[], any>({ + async queryFn(formData, _api, _extraOpts, _fetchWithBQ) { + if (formData.domains == undefined || formData.domains.length == 0) { + throw "No domains entered"; + } + + // Parse + tidy up the form data. + const permissions = parseDomainList(formData.domains); + const deduped = deduplicateDomainList(permissions); + const validated = validateDomainList(deduped); + + validated.forEach((entry) => { + // Set unique key that stays stable + // even if domain gets modified by user. + entry.key = nanoid(); + }); + + return { data: validated }; + } + }) + }) +}); + +/** + * useProcessDomainPermissionsMutation uses the RTK Query API without actually + * hitting the GtS API, it's purely an internal function for our own convenience. + * + * It returns the validated and deduplicated domain permission list. + */ +const useProcessDomainPermissionsMutation = extended.useProcessDomainPermissionsMutation; + +export { useProcessDomainPermissionsMutation }; diff --git a/web/source/settings/lib/query/admin/domain-permissions/update.ts b/web/source/settings/lib/query/admin/domain-permissions/update.ts new file mode 100644 index 000000000..a6b4b2039 --- /dev/null +++ b/web/source/settings/lib/query/admin/domain-permissions/update.ts @@ -0,0 +1,109 @@ +/* + GoToSocial + Copyright (C) GoToSocial Authors admin@gotosocial.org + SPDX-License-Identifier: AGPL-3.0-or-later + + 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/>. +*/ + +import { gtsApi } from "../../gts-api"; + +import { + replaceCacheOnMutation, + removeFromCacheOnMutation, +} from "../../query-modifiers"; +import { listToKeyedObject } from "../../transforms"; +import type { + DomainPerm, + MappedDomainPerms +} from "../../../types/domain-permission"; + +const extended = gtsApi.injectEndpoints({ + endpoints: (build) => ({ + addDomainBlock: build.mutation<MappedDomainPerms, any>({ + query: (formData) => ({ + method: "POST", + url: `/api/v1/admin/domain_blocks`, + asForm: true, + body: formData, + discardEmpty: true + }), + transformResponse: listToKeyedObject<DomainPerm>("domain"), + ...replaceCacheOnMutation("domainBlocks"), + }), + + addDomainAllow: build.mutation<MappedDomainPerms, any>({ + query: (formData) => ({ + method: "POST", + url: `/api/v1/admin/domain_allows`, + asForm: true, + body: formData, + discardEmpty: true + }), + transformResponse: listToKeyedObject<DomainPerm>("domain"), + ...replaceCacheOnMutation("domainAllows") + }), + + removeDomainBlock: build.mutation<DomainPerm, string>({ + query: (id) => ({ + method: "DELETE", + url: `/api/v1/admin/domain_blocks/${id}`, + }), + ...removeFromCacheOnMutation("domainBlocks", { + key: (_draft, newData) => { + return newData.domain; + } + }) + }), + + removeDomainAllow: build.mutation<DomainPerm, string>({ + query: (id) => ({ + method: "DELETE", + url: `/api/v1/admin/domain_allows/${id}`, + }), + ...removeFromCacheOnMutation("domainAllows", { + key: (_draft, newData) => { + return newData.domain; + } + }) + }), + }), +}); + +/** + * Add a single domain permission (block) by POSTing to `/api/v1/admin/domain_blocks`. + */ +const useAddDomainBlockMutation = extended.useAddDomainBlockMutation; + +/** + * Add a single domain permission (allow) by POSTing to `/api/v1/admin/domain_allows`. + */ +const useAddDomainAllowMutation = extended.useAddDomainAllowMutation; + +/** + * Remove a single domain permission (block) by DELETEing to `/api/v1/admin/domain_blocks/{id}`. + */ +const useRemoveDomainBlockMutation = extended.useRemoveDomainBlockMutation; + +/** + * Remove a single domain permission (allow) by DELETEing to `/api/v1/admin/domain_allows/{id}`. + */ +const useRemoveDomainAllowMutation = extended.useRemoveDomainAllowMutation; + +export { + useAddDomainBlockMutation, + useAddDomainAllowMutation, + useRemoveDomainBlockMutation, + useRemoveDomainAllowMutation +}; diff --git a/web/source/settings/lib/query/admin/import-export.js b/web/source/settings/lib/query/admin/import-export.js deleted file mode 100644 index 9a04438c2..000000000 --- a/web/source/settings/lib/query/admin/import-export.js +++ /dev/null @@ -1,264 +0,0 @@ -/* - GoToSocial - Copyright (C) GoToSocial Authors admin@gotosocial.org - SPDX-License-Identifier: AGPL-3.0-or-later - - 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/>. -*/ - -const Promise = require("bluebird"); -const fileDownload = require("js-file-download"); -const csv = require("papaparse"); -const { nanoid } = require("nanoid"); - -const { isValidDomainBlock, hasBetterScope } = require("../../domain-block"); - -const { - replaceCacheOnMutation, - domainListToObject, - unwrapRes -} = require("../lib"); - -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(); - let valid = true; - if (domain.startsWith("http")) { - try { - domain = new URL(domain).hostname; - } catch (e) { - valid = false; - } - } - return domain.length > 0 - ? { domain, valid } - : null; - }).filter((a) => a); // not `null` - } -} - -function validateDomainList(list) { - list.forEach((entry) => { - 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; - }); - - return list; -} - -function deduplicateDomainList(list) { - let domains = new Set(); - return list.filter((entry) => { - if (domains.has(entry.domain)) { - return false; - } else { - domains.add(entry.domain); - return true; - } - }); -} - -module.exports = (build) => ({ - processDomainList: build.mutation({ - queryFn: (formData) => { - return Promise.try(() => { - if (formData.domains == undefined || formData.domains.length == 0) { - throw "No domains entered"; - } - return parseDomainList(formData.domains); - }).then((parsed) => { - return deduplicateDomainList(parsed); - }).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() }; - }); - } - }), - 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(process.transformEntry); - }).then((exportList) => { - return process.stringify(exportList); - }).then((exportAsString) => { - if (formData.action == "export") { - return { - data: exportAsString - }; - } else if (formData.action == "export-file") { - let domain = new URL(api.getState().oauth.instance).host; - let date = new Date(); - - let filename = [ - domain, - "blocklist", - date.getFullYear(), - (date.getMonth() + 1).toString().padStart(2, "0"), - date.getDate().toString().padStart(2, "0"), - ].join("-"); - - fileDownload( - exportAsString, - filename + process.extension, - process.mime - ); - } - return { data: null }; - }).catch((e) => { - return { error: e }; - }); - } - }), - importDomainList: build.mutation({ - query: (formData) => { - const { domains } = formData; - - // add/replace comments, obfuscation data - let process = entryProcessor(formData); - domains.forEach((entry) => { - process(entry); - }); - - return { - method: "POST", - url: `/api/v1/admin/domain_blocks?import=true`, - asForm: true, - discardEmpty: true, - body: { - domains: new Blob([JSON.stringify(domains)], { type: "application/json" }) - } - }; - }, - transformResponse: domainListToObject, - ...replaceCacheOnMutation("instanceBlocks") - }) -}); - -const internalKeys = new Set("key,suggest,valid,checked".split(",")); -function entryProcessor(formData) { - let funcs = []; - - ["private_comment", "public_comment"].forEach((type) => { - let text = formData[type].trim(); - - if (text.length > 0) { - let behavior = formData[`${type}_behavior`]; - - if (behavior == "append") { - funcs.push(function appendComment(entry) { - if (entry[type] == undefined) { - entry[type] = text; - } else { - entry[type] = [entry[type], text].join("\n"); - } - }); - } else if (behavior == "replace") { - funcs.push(function replaceComment(entry) { - entry[type] = text; - }); - } - } - }); - - return function process(entry) { - funcs.forEach((func) => { - func(entry); - }); - - entry.obfuscate = formData.obfuscate; - - Object.entries(entry).forEach(([key, val]) => { - if (internalKeys.has(key) || val == undefined) { - delete entry[key]; - } - }); - }; -}
\ No newline at end of file diff --git a/web/source/settings/lib/query/admin/index.js b/web/source/settings/lib/query/admin/index.js deleted file mode 100644 index 7a55389d3..000000000 --- a/web/source/settings/lib/query/admin/index.js +++ /dev/null @@ -1,165 +0,0 @@ -/* - GoToSocial - Copyright (C) GoToSocial Authors admin@gotosocial.org - SPDX-License-Identifier: AGPL-3.0-or-later - - 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/>. -*/ - -const { - replaceCacheOnMutation, - removeFromCacheOnMutation, - domainListToObject, - idListToObject -} = require("../lib"); -const { gtsApi } = require("../gts-api"); - -const endpoints = (build) => ({ - updateInstance: build.mutation({ - query: (formData) => ({ - method: "PATCH", - url: `/api/v1/instance`, - asForm: true, - body: formData, - discardEmpty: true - }), - ...replaceCacheOnMutation("instance") - }), - mediaCleanup: build.mutation({ - query: (days) => ({ - method: "POST", - url: `/api/v1/admin/media_cleanup`, - params: { - remote_cache_days: days - } - }) - }), - instanceKeysExpire: build.mutation({ - query: (domain) => ({ - method: "POST", - url: `/api/v1/admin/domain_keys_expire`, - params: { - domain: domain - } - }) - }), - instanceBlocks: build.query({ - query: () => ({ - url: `/api/v1/admin/domain_blocks` - }), - transformResponse: domainListToObject - }), - addInstanceBlock: build.mutation({ - query: (formData) => ({ - method: "POST", - url: `/api/v1/admin/domain_blocks`, - asForm: true, - body: formData, - discardEmpty: true - }), - transformResponse: (data) => { - return { - [data.domain]: data - }; - }, - ...replaceCacheOnMutation("instanceBlocks") - }), - removeInstanceBlock: build.mutation({ - query: (id) => ({ - method: "DELETE", - url: `/api/v1/admin/domain_blocks/${id}`, - }), - ...removeFromCacheOnMutation("instanceBlocks", { - findKey: (_draft, newData) => { - return newData.domain; - } - }) - }), - getAccount: build.query({ - query: (id) => ({ - url: `/api/v1/accounts/${id}` - }), - providesTags: (_, __, id) => [{ type: "Account", id }] - }), - actionAccount: build.mutation({ - query: ({ id, action, reason }) => ({ - method: "POST", - url: `/api/v1/admin/accounts/${id}/action`, - asForm: true, - body: { - type: action, - text: reason - } - }), - invalidatesTags: (_, __, { id }) => [{ type: "Account", id }] - }), - searchAccount: build.mutation({ - query: (username) => ({ - url: `/api/v2/search?q=${encodeURIComponent(username)}&resolve=true` - }), - transformResponse: (res) => { - return res.accounts ?? []; - } - }), - instanceRules: build.query({ - query: () => ({ - url: `/api/v1/admin/instance/rules` - }), - transformResponse: idListToObject - }), - addInstanceRule: build.mutation({ - query: (formData) => ({ - method: "POST", - url: `/api/v1/admin/instance/rules`, - asForm: true, - body: formData, - discardEmpty: true - }), - transformResponse: (data) => { - return { - [data.id]: data - }; - }, - ...replaceCacheOnMutation("instanceRules") - }), - updateInstanceRule: build.mutation({ - query: ({ id, ...edit }) => ({ - method: "PATCH", - url: `/api/v1/admin/instance/rules/${id}`, - asForm: true, - body: edit, - discardEmpty: true - }), - transformResponse: (data) => { - return { - [data.id]: data - }; - }, - ...replaceCacheOnMutation("instanceRules") - }), - deleteInstanceRule: build.mutation({ - query: (id) => ({ - method: "DELETE", - url: `/api/v1/admin/instance/rules/${id}` - }), - ...removeFromCacheOnMutation("instanceRules", { - findKey: (_draft, rule) => rule.id - }) - }), - ...require("./import-export")(build), - ...require("./custom-emoji")(build), - ...require("./reports")(build) -}); - -module.exports = gtsApi.injectEndpoints({ endpoints });
\ No newline at end of file diff --git a/web/source/settings/lib/query/admin/index.ts b/web/source/settings/lib/query/admin/index.ts new file mode 100644 index 000000000..e61179216 --- /dev/null +++ b/web/source/settings/lib/query/admin/index.ts @@ -0,0 +1,148 @@ +/* + GoToSocial + Copyright (C) GoToSocial Authors admin@gotosocial.org + SPDX-License-Identifier: AGPL-3.0-or-later + + 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/>. +*/ + +import { replaceCacheOnMutation, removeFromCacheOnMutation } from "../query-modifiers"; +import { gtsApi } from "../gts-api"; +import { listToKeyedObject } from "../transforms"; + +const extended = gtsApi.injectEndpoints({ + endpoints: (build) => ({ + updateInstance: build.mutation({ + query: (formData) => ({ + method: "PATCH", + url: `/api/v1/instance`, + asForm: true, + body: formData, + discardEmpty: true + }), + ...replaceCacheOnMutation("instanceV1"), + }), + + mediaCleanup: build.mutation({ + query: (days) => ({ + method: "POST", + url: `/api/v1/admin/media_cleanup`, + params: { + remote_cache_days: days + } + }) + }), + + instanceKeysExpire: build.mutation({ + query: (domain) => ({ + method: "POST", + url: `/api/v1/admin/domain_keys_expire`, + params: { + domain: domain + } + }) + }), + + getAccount: build.query({ + query: (id) => ({ + url: `/api/v1/accounts/${id}` + }), + providesTags: (_, __, id) => [{ type: "Account", id }] + }), + + actionAccount: build.mutation({ + query: ({ id, action, reason }) => ({ + method: "POST", + url: `/api/v1/admin/accounts/${id}/action`, + asForm: true, + body: { + type: action, + text: reason + } + }), + invalidatesTags: (_, __, { id }) => [{ type: "Account", id }] + }), + + searchAccount: build.mutation({ + query: (username) => ({ + url: `/api/v2/search?q=${encodeURIComponent(username)}&resolve=true` + }), + transformResponse: (res) => { + return res.accounts ?? []; + } + }), + + instanceRules: build.query({ + query: () => ({ + url: `/api/v1/admin/instance/rules` + }), + transformResponse: listToKeyedObject<any>("id") + }), + + addInstanceRule: build.mutation({ + query: (formData) => ({ + method: "POST", + url: `/api/v1/admin/instance/rules`, + asForm: true, + body: formData, + discardEmpty: true + }), + transformResponse: (data) => { + return { + [data.id]: data + }; + }, + ...replaceCacheOnMutation("instanceRules"), + }), + + updateInstanceRule: build.mutation({ + query: ({ id, ...edit }) => ({ + method: "PATCH", + url: `/api/v1/admin/instance/rules/${id}`, + asForm: true, + body: edit, + discardEmpty: true + }), + transformResponse: (data) => { + return { + [data.id]: data + }; + }, + ...replaceCacheOnMutation("instanceRules"), + }), + + deleteInstanceRule: build.mutation({ + query: (id) => ({ + method: "DELETE", + url: `/api/v1/admin/instance/rules/${id}` + }), + ...removeFromCacheOnMutation("instanceRules", { + key: (_draft, rule) => rule.id, + }) + }) + }) +}); + +export const { + useUpdateInstanceMutation, + useMediaCleanupMutation, + useInstanceKeysExpireMutation, + useGetAccountQuery, + useActionAccountMutation, + useSearchAccountMutation, + useInstanceRulesQuery, + useAddInstanceRuleMutation, + useUpdateInstanceRuleMutation, + useDeleteInstanceRuleMutation, +} = extended; diff --git a/web/source/settings/lib/query/admin/reports.js b/web/source/settings/lib/query/admin/reports.js deleted file mode 100644 index 1c45bb7bc..000000000 --- a/web/source/settings/lib/query/admin/reports.js +++ /dev/null @@ -1,51 +0,0 @@ -/* - GoToSocial - Copyright (C) GoToSocial Authors admin@gotosocial.org - SPDX-License-Identifier: AGPL-3.0-or-later - - 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/>. -*/ - -module.exports = (build) => ({ - listReports: build.query({ - query: (params = {}) => ({ - url: "/api/v1/admin/reports", - params: { - limit: 100, - ...params - } - }), - providesTags: ["Reports"] - }), - - getReport: build.query({ - query: (id) => ({ - url: `/api/v1/admin/reports/${id}` - }), - providesTags: (res, error, id) => [{ type: "Reports", id }] - }), - - resolveReport: build.mutation({ - query: (formData) => ({ - url: `/api/v1/admin/reports/${formData.id}/resolve`, - method: "POST", - asForm: true, - body: formData - }), - invalidatesTags: (res) => - res - ? [{ type: "Reports", id: "LIST" }, { type: "Reports", id: res.id }] - : [{ type: "Reports", id: "LIST" }] - }) -});
\ No newline at end of file diff --git a/web/source/settings/lib/query/admin/reports/index.ts b/web/source/settings/lib/query/admin/reports/index.ts new file mode 100644 index 000000000..253e8238c --- /dev/null +++ b/web/source/settings/lib/query/admin/reports/index.ts @@ -0,0 +1,83 @@ +/* + GoToSocial + Copyright (C) GoToSocial Authors admin@gotosocial.org + SPDX-License-Identifier: AGPL-3.0-or-later + + 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/>. +*/ + +import { gtsApi } from "../../gts-api"; + +import type { + AdminReport, + AdminReportListParams, + AdminReportResolveParams, +} from "../../../types/report"; + +const extended = gtsApi.injectEndpoints({ + endpoints: (build) => ({ + listReports: build.query<AdminReport[], AdminReportListParams | void>({ + query: (params) => ({ + url: "/api/v1/admin/reports", + params: { + // Override provided limit. + limit: 100, + ...params + } + }), + providesTags: ["Reports"] + }), + + getReport: build.query<AdminReport, string>({ + query: (id) => ({ + url: `/api/v1/admin/reports/${id}` + }), + providesTags: (_res, _error, id) => [{ type: "Reports", id }] + }), + + resolveReport: build.mutation<AdminReport, AdminReportResolveParams>({ + query: (formData) => ({ + url: `/api/v1/admin/reports/${formData.id}/resolve`, + method: "POST", + asForm: true, + body: formData + }), + invalidatesTags: (res) => + res + ? [{ type: "Reports", id: "LIST" }, { type: "Reports", id: res.id }] + : [{ type: "Reports", id: "LIST" }] + }) + }) +}); + +/** + * List reports received on this instance, filtered using given parameters. + */ +const useListReportsQuery = extended.useListReportsQuery; + +/** + * Get a single report by its ID. + */ +const useGetReportQuery = extended.useGetReportQuery; + +/** + * Mark an open report as resolved. + */ +const useResolveReportMutation = extended.useResolveReportMutation; + +export { + useListReportsQuery, + useGetReportQuery, + useResolveReportMutation, +}; diff --git a/web/source/settings/lib/query/gts-api.ts b/web/source/settings/lib/query/gts-api.ts index 9e043137c..a07f5ff1e 100644 --- a/web/source/settings/lib/query/gts-api.ts +++ b/web/source/settings/lib/query/gts-api.ts @@ -26,6 +26,7 @@ import type { import { serialize as serializeForm } from "object-to-formdata"; import type { RootState } from '../../redux/store'; +import { InstanceV1 } from '../types/instance'; /** * GTSFetchArgs extends standard FetchArgs used by @@ -72,7 +73,7 @@ const gtsBaseQuery: BaseQueryFn< const { instanceUrl, token } = state.oauth; // Derive baseUrl dynamically. - let baseUrl: string; + let baseUrl: string | undefined; // Check if simple string baseUrl provided // as args, or if more complex args provided. @@ -137,8 +138,8 @@ export const gtsApi = createApi({ "Account", "InstanceRules", ], - endpoints: (builder) => ({ - instance: builder.query<any, void>({ + endpoints: (build) => ({ + instanceV1: build.query<InstanceV1, void>({ query: () => ({ url: `/api/v1/instance` }) @@ -146,4 +147,11 @@ export const gtsApi = createApi({ }) }); -export const { useInstanceQuery } = gtsApi; +/** + * Query /api/v1/instance to retrieve basic instance information. + * This endpoint does not require authentication/authorization. + * TODO: move this to ./instance. + */ +const useInstanceV1Query = gtsApi.useInstanceV1Query; + +export { useInstanceV1Query }; diff --git a/web/source/settings/lib/query/lib.js b/web/source/settings/lib/query/lib.js deleted file mode 100644 index 1025ca3a7..000000000 --- a/web/source/settings/lib/query/lib.js +++ /dev/null @@ -1,81 +0,0 @@ -/* - GoToSocial - Copyright (C) GoToSocial Authors admin@gotosocial.org - SPDX-License-Identifier: AGPL-3.0-or-later - - 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/>. -*/ - -const syncpipe = require("syncpipe"); -const { gtsApi } = require("./gts-api"); - -module.exports = { - unwrapRes(res) { - if (res.error != undefined) { - throw res.error; - } else { - return res.data; - } - }, - domainListToObject: (data) => { - // Turn flat Array into Object keyed by block's domain - return syncpipe(data, [ - (_) => _.map((entry) => [entry.domain, entry]), - (_) => Object.fromEntries(_) - ]); - }, - idListToObject: (data) => { - // Turn flat Array into Object keyed by entry id field - return syncpipe(data, [ - (_) => _.map((entry) => [entry.id, entry]), - (_) => Object.fromEntries(_) - ]); - }, - replaceCacheOnMutation: makeCacheMutation((draft, newData) => { - Object.assign(draft, newData); - }), - appendCacheOnMutation: makeCacheMutation((draft, newData) => { - draft.push(newData); - }), - spliceCacheOnMutation: makeCacheMutation((draft, newData, { key }) => { - draft.splice(key, 1); - }), - updateCacheOnMutation: makeCacheMutation((draft, newData, { key }) => { - draft[key] = newData; - }), - removeFromCacheOnMutation: makeCacheMutation((draft, newData, { key }) => { - delete draft[key]; - }), - editCacheOnMutation: makeCacheMutation((draft, newData, { update }) => { - update(draft, newData); - }) -}; - -// https://redux-toolkit.js.org/rtk-query/usage/manual-cache-updates#pessimistic-updates -function makeCacheMutation(action) { - return function cacheMutation(queryName, { key, findKey, arg, ...opts } = {}) { - return { - onQueryStarted: (_, { dispatch, queryFulfilled }) => { - queryFulfilled.then(({ data: newData }) => { - dispatch(gtsApi.util.updateQueryData(queryName, arg, (draft) => { - if (findKey != undefined) { - key = findKey(draft, newData); - } - action(draft, newData, { key, ...opts }); - })); - }); - } - }; - }; -}
\ No newline at end of file diff --git a/web/source/settings/lib/query/oauth/index.ts b/web/source/settings/lib/query/oauth/index.ts index 9af2dd5fb..f62a29596 100644 --- a/web/source/settings/lib/query/oauth/index.ts +++ b/web/source/settings/lib/query/oauth/index.ts @@ -57,8 +57,8 @@ const SETTINGS_URL = (getSettingsURL()); // // https://redux-toolkit.js.org/rtk-query/usage/customizing-queries#performing-multiple-requests-with-a-single-query const extended = gtsApi.injectEndpoints({ - endpoints: (builder) => ({ - verifyCredentials: builder.query<any, void>({ + endpoints: (build) => ({ + verifyCredentials: build.query<any, void>({ providesTags: (_res, error) => error == undefined ? ["Auth"] : [], async queryFn(_arg, api, _extraOpts, fetchWithBQ) { @@ -135,7 +135,7 @@ const extended = gtsApi.injectEndpoints({ } }), - authorizeFlow: builder.mutation({ + authorizeFlow: build.mutation({ async queryFn(formData, api, _extraOpts, fetchWithBQ) { const state = api.getState() as RootState; const oauthState = state.oauth; @@ -187,7 +187,7 @@ const extended = gtsApi.injectEndpoints({ return { data: null }; }, }), - logout: builder.mutation({ + logout: build.mutation({ queryFn: (_arg, api) => { api.dispatch(oauthRemove()); return { data: null }; @@ -201,4 +201,4 @@ export const { useVerifyCredentialsQuery, useAuthorizeFlowMutation, useLogoutMutation, -} = extended;
\ No newline at end of file +} = extended; diff --git a/web/source/settings/lib/query/query-modifiers.ts b/web/source/settings/lib/query/query-modifiers.ts new file mode 100644 index 000000000..d6bf0b6ae --- /dev/null +++ b/web/source/settings/lib/query/query-modifiers.ts @@ -0,0 +1,150 @@ +/* + GoToSocial + Copyright (C) GoToSocial Authors admin@gotosocial.org + SPDX-License-Identifier: AGPL-3.0-or-later + + 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/>. +*/ + +import { gtsApi } from "./gts-api"; + +import type { + Action, + CacheMutation, +} from "../types/query"; + +import { NoArg } from "../types/query"; + +/** + * Cache mutation creator for pessimistic updates. + * + * Feed it a function that you want to perform on the + * given draft and updated data, using the given parameters. + * + * https://redux-toolkit.js.org/rtk-query/api/createApi#onquerystarted + * https://redux-toolkit.js.org/rtk-query/usage/manual-cache-updates#pessimistic-updates + */ +function makeCacheMutation(action: Action): CacheMutation { + return function cacheMutation( + queryName: string | ((_arg: any) => string), + { key } = {}, + ) { + return { + onQueryStarted: async(mutationData, { dispatch, queryFulfilled }) => { + // queryName might be a function that returns + // a query name; trigger it if so. The returned + // queryName has to match one of the API endpoints + // we've defined. So if we have endpoints called + // (for example) `instanceV1` and `getPosts` then + // the queryName provided here has to line up with + // one of those in order to actually do anything. + if (typeof queryName !== "string") { + queryName = queryName(mutationData); + } + + if (queryName == "") { + throw ( + "provided queryName resolved to an empty string;" + + "double check your mutation definition!" + ); + } + + try { + // Wait for the mutation to finish (this + // is why it's a pessimistic update). + const { data: newData } = await queryFulfilled; + + // In order for `gtsApi.util.updateQueryData` to + // actually do something within a dispatch, the + // first two arguments passed into it have to line + // up with arguments that were used earlier to + // fetch the data whose cached version we're now + // trying to modify. + // + // So, if we earlier fetched all reports with + // queryName `getReports`, and arg `undefined`, + // then we now need match those parameters in + // `updateQueryData` in order to modify the cache. + // + // If you pass something like `null` or `""` here + // instead, then the cache will not get modified! + // Redux will just quietly discard the thunk action. + dispatch( + gtsApi.util.updateQueryData(queryName as any, NoArg, (draft) => { + if (key != undefined && typeof key !== "string") { + key = key(draft, newData); + } + action(draft, newData, { key }); + }) + ); + } catch (e) { + // eslint-disable-next-line no-console + console.error(`rolling back pessimistic update of ${queryName}: ${e}`); + } + } + }; + }; +} + +/** + * + */ +const replaceCacheOnMutation: CacheMutation = makeCacheMutation((draft, newData, _params) => { + Object.assign(draft, newData); +}); + +const appendCacheOnMutation: CacheMutation = makeCacheMutation((draft, newData, _params) => { + draft.push(newData); +}); + +const spliceCacheOnMutation: CacheMutation = makeCacheMutation((draft, _newData, { key }) => { + if (key === undefined) { + throw ("key undefined"); + } + + draft.splice(key, 1); +}); + +const updateCacheOnMutation: CacheMutation = makeCacheMutation((draft, newData, { key }) => { + if (key === undefined) { + throw ("key undefined"); + } + + if (typeof key !== "string") { + key = key(draft, newData); + } + + draft[key] = newData; +}); + +const removeFromCacheOnMutation: CacheMutation = makeCacheMutation((draft, newData, { key }) => { + if (key === undefined) { + throw ("key undefined"); + } + + if (typeof key !== "string") { + key = key(draft, newData); + } + + delete draft[key]; +}); + + +export { + replaceCacheOnMutation, + appendCacheOnMutation, + spliceCacheOnMutation, + updateCacheOnMutation, + removeFromCacheOnMutation, +}; diff --git a/web/source/settings/lib/query/transforms.ts b/web/source/settings/lib/query/transforms.ts new file mode 100644 index 000000000..d915e0b13 --- /dev/null +++ b/web/source/settings/lib/query/transforms.ts @@ -0,0 +1,78 @@ +/* + GoToSocial + Copyright (C) GoToSocial Authors admin@gotosocial.org + SPDX-License-Identifier: AGPL-3.0-or-later + + 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/>. +*/ + +/** + * Map a list of items into an object. + * + * In the following example, a list of DomainPerms like the following: + * + * ```json + * [ + * { + * "domain": "example.org", + * "public_comment": "aaaaa!!" + * }, + * { + * "domain": "another.domain", + * "public_comment": "they are poo" + * } + * ] + * ``` + * + * Would be converted into an Object like the following: + * + * ```json + * { + * "example.org": { + * "domain": "example.org", + * "public_comment": "aaaaa!!" + * }, + * "another.domain": { + * "domain": "another.domain", + * "public_comment": "they are poo" + * }, + * } + * ``` + * + * If you pass a non-array type into this function it + * will be converted into an array first, as a treat. + * + * @example + * const extended = gtsApi.injectEndpoints({ + * endpoints: (build) => ({ + * getDomainBlocks: build.query<MappedDomainPerms, void>({ + * query: () => ({ + * url: `/api/v1/admin/domain_blocks` + * }), + * transformResponse: listToKeyedObject<DomainPerm>("domain"), + * }), + * }); + */ +export function listToKeyedObject<T>(key: keyof T) { + return (list: T[] | T): { [_ in keyof T]: T } => { + // Ensure we're actually + // dealing with an array. + if (!Array.isArray(list)) { + list = [list]; + } + + const entries = list.map((entry) => [entry[key], entry]); + return Object.fromEntries(entries); + }; +} diff --git a/web/source/settings/lib/query/user/index.ts b/web/source/settings/lib/query/user/index.ts index 751e38e5b..a7cdad2fd 100644 --- a/web/source/settings/lib/query/user/index.ts +++ b/web/source/settings/lib/query/user/index.ts @@ -17,12 +17,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. */ -import { replaceCacheOnMutation } from "../lib"; +import { replaceCacheOnMutation } from "../query-modifiers"; import { gtsApi } from "../gts-api"; const extended = gtsApi.injectEndpoints({ - endpoints: (builder) => ({ - updateCredentials: builder.mutation({ + endpoints: (build) => ({ + updateCredentials: build.mutation({ query: (formData) => ({ method: "PATCH", url: `/api/v1/accounts/update_credentials`, @@ -32,7 +32,7 @@ const extended = gtsApi.injectEndpoints({ }), ...replaceCacheOnMutation("verifyCredentials") }), - passwordChange: builder.mutation({ + passwordChange: build.mutation({ query: (data) => ({ method: "POST", url: `/api/v1/user/password_change`, diff --git a/web/source/settings/admin/federation/index.js b/web/source/settings/lib/types/custom-emoji.ts index ec536c0be..f54e9e2a0 100644 --- a/web/source/settings/admin/federation/index.js +++ b/web/source/settings/lib/types/custom-emoji.ts @@ -17,25 +17,33 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. */ -const React = require("react"); -const { Switch, Route } = require("wouter"); - -const InstanceOverview = require("./overview"); -const InstanceDetail = require("./detail"); -const InstanceImportExport = require("./import-export"); - -module.exports = function Federation({ baseUrl }) { - return ( - <Switch> - <Route path={`${baseUrl}/import-export/:list?`}> - <InstanceImportExport /> - </Route> - - <Route path={`${baseUrl}/:domain`}> - <InstanceDetail baseUrl={baseUrl} /> - </Route> - - <InstanceOverview baseUrl={baseUrl} /> - </Switch> - ); -};
\ No newline at end of file +export interface CustomEmoji { + id?: string; + shortcode: string; + category?: string; +} + +/** + * Query parameters for GET to /api/v1/admin/custom_emojis. + */ +export interface ListEmojiParams { + +} + +/** + * Result of searchItemForEmoji mutation. + */ +export interface EmojisFromItem { + /** + * Type of the search item result. + */ + type: "statuses" | "accounts"; + /** + * Domain of the returned emojis. + */ + domain: string; + /** + * Discovered emojis. + */ + list: CustomEmoji[]; +} diff --git a/web/source/settings/lib/types/domain-permission.ts b/web/source/settings/lib/types/domain-permission.ts new file mode 100644 index 000000000..f90c8d8a9 --- /dev/null +++ b/web/source/settings/lib/types/domain-permission.ts @@ -0,0 +1,97 @@ +/* + GoToSocial + Copyright (C) GoToSocial Authors admin@gotosocial.org + SPDX-License-Identifier: AGPL-3.0-or-later + + 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/>. +*/ + +import typia from "typia"; + +export const isDomainPerms = typia.createIs<DomainPerm[]>(); + +export type PermType = "block" | "allow"; + +/** + * A single domain permission entry (block or allow). + */ +export interface DomainPerm { + id?: string; + domain: string; + obfuscate?: boolean; + private_comment?: string; + public_comment?: string; + created_at?: string; + + // Internal processing keys; remove + // before serdes of domain perm. + key?: string; + permType?: PermType; + suggest?: string; + valid?: boolean; + checked?: boolean; + commentType?: string; + private_comment_behavior?: "append" | "replace"; + public_comment_behavior?: "append" | "replace"; +} + +/** + * Domain permissions mapped to an Object where the Object + * keys are the "domain" value of each DomainPerm. + */ +export interface MappedDomainPerms { + [key: string]: DomainPerm; +} + +const domainPermInternalKeys: Set<keyof DomainPerm> = new Set([ + "key", + "permType", + "suggest", + "valid", + "checked", + "commentType", + "private_comment_behavior", + "public_comment_behavior", +]); + +/** + * Returns true if provided DomainPerm Object key is + * "internal"; ie., it's just for our use, and it shouldn't + * be serialized to or deserialized from the GtS API. + * + * @param key + * @returns + */ +export function isDomainPermInternalKey(key: keyof DomainPerm) { + return domainPermInternalKeys.has(key); +} + +export interface ImportDomainPermsParams { + domains: DomainPerm[]; + + // Internal processing keys; + // remove before serdes of form. + obfuscate?: boolean; + commentType?: string; + permType: PermType; +} + +/** + * Model domain permissions bulk export params. + */ +export interface ExportDomainPermsParams { + permType: PermType; + action: "export" | "export-file"; + exportType: "json" | "csv" | "plain"; +} diff --git a/web/source/settings/lib/types/instance.ts b/web/source/settings/lib/types/instance.ts new file mode 100644 index 000000000..a0a75366e --- /dev/null +++ b/web/source/settings/lib/types/instance.ts @@ -0,0 +1,91 @@ +/* + GoToSocial + Copyright (C) GoToSocial Authors admin@gotosocial.org + SPDX-License-Identifier: AGPL-3.0-or-later + + 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/>. +*/ + +export interface InstanceV1 { + uri: string; + account_domain: string; + title: string; + description: string; + short_description: string; + email: string; + version: string; + languages: any[]; // TODO: define this + registrations: boolean; + approval_required: boolean; + invites_enabled: boolean; + configuration: InstanceConfiguration; + urls: InstanceUrls; + stats: InstanceStats; + thumbnail: string; + contact_account: Object; // TODO: define this. + max_toot_chars: number; + rules: any[]; // TODO: define this +} + +export interface InstanceConfiguration { + statuses: InstanceStatuses; + media_attachments: InstanceMediaAttachments; + polls: InstancePolls; + accounts: InstanceAccounts; + emojis: InstanceEmojis; +} + +export interface InstanceAccounts { + allow_custom_css: boolean; + max_featured_tags: number; + max_profile_fields: number; +} + +export interface InstanceEmojis { + emoji_size_limit: number; +} + +export interface InstanceMediaAttachments { + supported_mime_types: string[]; + image_size_limit: number; + image_matrix_limit: number; + video_size_limit: number; + video_frame_rate_limit: number; + video_matrix_limit: number; +} + +export interface InstancePolls { + max_options: number; + max_characters_per_option: number; + min_expiration: number; + max_expiration: number; +} + +export interface InstanceStatuses { + max_characters: number; + max_media_attachments: number; + characters_reserved_per_url: number; + supported_mime_types: string[]; +} + +export interface InstanceStats { + domain_count: number; + status_count: number; + user_count: number; +} + +export interface InstanceUrls { + streaming_api: string; +} + diff --git a/web/source/settings/lib/types/query.ts b/web/source/settings/lib/types/query.ts new file mode 100644 index 000000000..8e6901b76 --- /dev/null +++ b/web/source/settings/lib/types/query.ts @@ -0,0 +1,95 @@ +/* + GoToSocial + Copyright (C) GoToSocial Authors admin@gotosocial.org + SPDX-License-Identifier: AGPL-3.0-or-later + + 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/>. +*/ + +import { Draft } from "@reduxjs/toolkit"; + +/** + * Pass into a query when you don't + * want to provide an argument to it. + */ +export const NoArg = undefined; + +/** + * Shadow the redux onQueryStarted function for mutations. + * https://redux-toolkit.js.org/rtk-query/api/createApi#onquerystarted + */ +type OnMutationStarted = ( + _arg: any, + _params: MutationStartedParams +) => Promise<void>; + +/** + * Shadow the redux onQueryStarted function parameters for mutations. + * https://redux-toolkit.js.org/rtk-query/api/createApi#onquerystarted + */ +interface MutationStartedParams { + /** + * The dispatch method for the store. + */ + dispatch, + /** + * A method to get the current state for the store. + */ + getState, + /** + * extra as provided as thunk.extraArgument to the configureStore getDefaultMiddleware option. + */ + extra, + /** + * A unique ID generated for the query/mutation. + */ + requestId, + /** + * A Promise that will resolve with a data property (the transformed query result), and a + * meta property (meta returned by the baseQuery). If the query fails, this Promise will + * reject with the error. This allows you to await for the query to finish. + */ + queryFulfilled, + /** + * A function that gets the current value of the cache entry. + */ + getCacheEntry, +} + +export type Action = ( + _draft: Draft<any>, + _updated: any, + _params: ActionParams, +) => void; + +export interface ActionParams { + /** + * Either a normal old string, or a custom + * function to derive the key to change based + * on the draft and updated data. + * + * @param _draft + * @param _updated + * @returns + */ + key?: string | ((_draft: Draft<any>, _updated: any) => string), +} + +/** + * Custom cache mutation. + */ +export type CacheMutation = ( + _queryName: string | ((_arg: any) => string), + _params?: ActionParams, +) => { onQueryStarted: OnMutationStarted } diff --git a/web/source/settings/lib/types/report.ts b/web/source/settings/lib/types/report.ts new file mode 100644 index 000000000..bb3d53c27 --- /dev/null +++ b/web/source/settings/lib/types/report.ts @@ -0,0 +1,144 @@ +/* + GoToSocial + Copyright (C) GoToSocial Authors admin@gotosocial.org + SPDX-License-Identifier: AGPL-3.0-or-later + + 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/>. +*/ + +/** + * Admin model of a report. Differs from the client + * model, which contains less detailed information. + */ +export interface AdminReport { + /** + * ID of the report. + */ + id: string; + /** + * Whether an action has been taken by an admin in response to this report. + */ + action_taken: boolean; + /** + * Time action was taken, if at all. + */ + action_taken_at?: string; + /** + * Category under which this report was created. + */ + category: string; + /** + * Comment submitted by the report creator. + */ + comment: string; + /** + * Report was/should be federated to remote instance. + */ + forwarded: boolean; + /** + * Time when the report was created. + */ + created_at: string; + /** + * Time when the report was last updated. + */ + updated_at: string; + /** + * Account that created the report. + * TODO: model this properly. + */ + account: Object; + /** + * Reported account. + * TODO: model this properly. + */ + target_account: Object; + /** + * Admin account assigned to handle this report, if any. + * TODO: model this properly. + */ + assigned_account?: Object; + /** + * Admin account that has taken action on this report, if any. + * TODO: model this properly. + */ + action_taken_by_account?: Object; + /** + * Statuses cited by this report, if any. + * TODO: model this properly. + */ + statuses: Object[]; + /** + * Rules broken according to the reporter, if any. + * TODO: model this properly. + */ + rules: Object[]; + /** + * Comment stored about what action (if any) was taken. + */ + action_taken_comment?: string; +} + +/** + * Parameters for POST to /api/v1/admin/reports/{id}/resolve. + */ +export interface AdminReportResolveParams { + /** + * The ID of the report to resolve. + */ + id: string; + /** + * Comment to store about what action (if any) was taken. + * Will be shown to the user who created the report (if local). + */ + action_taken_comment?: string; +} + +/** + * Parameters for GET to /api/v1/admin/reports. + */ +export interface AdminReportListParams { + /** + * If set, show only resolved (true) or only unresolved (false) reports. + */ + resolved?: boolean; + /** + * If set, show only reports created by the given account ID. + */ + account_id?: string; + /** + * If set, show only reports that target the given account ID. + */ + target_account_id?: string; + /** + * If set, show only reports older (ie., lower) than the given ID. + * Report with the given ID will not be included in response. + */ + max_id?: string; + /** + * If set, show only reports newer (ie., higher) than the given ID. + * Report with the given ID will not be included in response. + */ + since_id?: string; + /** + * If set, show only reports *immediately newer* than the given ID. + * Report with the given ID will not be included in response. + */ + min_id?: string; + /** + * If set, limit returned reports to this number. + * Else, fall back to GtS API defaults. + */ + limit?: number; +} diff --git a/web/source/settings/lib/domain-block.js b/web/source/settings/lib/util/domain-permission.ts index e1cbd4c22..b8dcbc8aa 100644 --- a/web/source/settings/lib/domain-block.js +++ b/web/source/settings/lib/util/domain-permission.ts @@ -17,33 +17,32 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. */ -const isValidDomain = require("is-valid-domain"); -const psl = require("psl"); +import isValidDomain from "is-valid-domain"; +import { get } from "psl"; -function isValidDomainBlock(domain) { +/** + * Check the input string to ensure it's a valid + * domain that doesn't include a wildcard ("*"). + * @param domain + * @returns + */ +export function isValidDomainPermission(domain: string): boolean { 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); +/** + * Checks 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. + * Eg., "social.example.com" suggests "example.com". + * @param domain + * @returns + */ +export function hasBetterScope(domain: string): string | undefined { + const lookup = get(domain); if (lookup && lookup != domain) { return lookup; - } else { - return false; } } - -module.exports = { isValidDomainBlock, hasBetterScope };
\ No newline at end of file diff --git a/web/source/settings/style.css b/web/source/settings/style.css index 505e3bbfc..524f5e4ab 100644 --- a/web/source/settings/style.css +++ b/web/source/settings/style.css @@ -498,7 +498,7 @@ span.form-info { } } -.instance-list { +.domain-permissions-list { p { margin-top: 0; } @@ -612,7 +612,7 @@ span.form-info { padding: 0.75rem; } - .instance-list .filter { + .domain-permissions-list .filter { flex-direction: column; } } @@ -809,7 +809,7 @@ button.with-padding { animation-fill-mode: forwards; } -.suspend-import-list { +.domain-perm-import-list { .checkbox-list-wrapper { overflow-x: auto; display: grid; @@ -844,7 +844,7 @@ button.with-padding { #icon { align-self: center; - .already-blocked { + .permission-already-exists { color: $green1; } @@ -875,6 +875,12 @@ button.with-padding { align-items: center; } + .form-field.radio { + display: flex; + flex-direction: column; + margin-left: 0.5rem; + } + .button-grid { display: inline-grid; grid-template-columns: auto auto auto; diff --git a/web/source/settings/user/profile.js b/web/source/settings/user/profile.js index 6c26bb406..b6daf175b 100644 --- a/web/source/settings/user/profile.js +++ b/web/source/settings/user/profile.js @@ -19,8 +19,6 @@ const React = require("react"); -const query = require("../lib/query"); - const { useTextInput, useFileInput, @@ -28,7 +26,7 @@ const { useFieldArrayInput } = require("../lib/form"); -const useFormSubmit = require("../lib/form/submit"); +const useFormSubmit = require("../lib/form/submit").default; const { useWithFormContext, FormContext } = require("../lib/form/context"); const { @@ -38,14 +36,18 @@ const { Checkbox } = require("../components/form/inputs"); -const FormWithData = require("../lib/form/form-with-data"); +const FormWithData = require("../lib/form/form-with-data").default; const FakeProfile = require("../components/fake-profile"); const MutationButton = require("../components/form/mutation-button"); +const { useInstanceV1Query } = require("../lib/query"); +const { useUpdateCredentialsMutation } = require("../lib/query/user"); +const { useVerifyCredentialsQuery } = require("../lib/query/oauth"); + module.exports = function UserProfile() { return ( <FormWithData - dataQuery={query.useVerifyCredentialsQuery} + dataQuery={useVerifyCredentialsQuery} DataForm={UserProfileForm} /> ); @@ -64,7 +66,7 @@ function UserProfileForm({ data: profile }) { - string custom_css (if enabled) */ - const { data: instance } = query.useInstanceQuery(); + const { data: instance } = useInstanceV1Query(); const instanceConfig = React.useMemo(() => { return { allowCustomCSS: instance?.configuration?.accounts?.allow_custom_css === true, @@ -88,7 +90,7 @@ function UserProfileForm({ data: profile }) { }), }; - const [submitForm, result] = useFormSubmit(form, query.useUpdateCredentialsMutation(), { + const [submitForm, result] = useFormSubmit(form, useUpdateCredentialsMutation(), { onFinish: () => { form.avatar.reset(); form.header.reset(); diff --git a/web/source/settings/user/settings.js b/web/source/settings/user/settings.js index 2a23a87e0..31ea8c39a 100644 --- a/web/source/settings/user/settings.js +++ b/web/source/settings/user/settings.js @@ -26,7 +26,7 @@ const { useBoolInput } = require("../lib/form"); -const useFormSubmit = require("../lib/form/submit"); +const useFormSubmit = require("../lib/form/submit").default; const { Select, @@ -34,7 +34,7 @@ const { Checkbox } = require("../components/form/inputs"); -const FormWithData = require("../lib/form/form-with-data"); +const FormWithData = require("../lib/form/form-with-data").default; const Languages = require("../components/languages"); const MutationButton = require("../components/form/mutation-button"); |