diff options
77 files changed, 4154 insertions, 1690 deletions
diff --git a/.drone.yml b/.drone.yml index c398db390..8e2aebb86 100644 --- a/.drone.yml +++ b/.drone.yml @@ -54,6 +54,7 @@ steps: path: /tmp/cache commands: - yarn --cwd ./web/source install --frozen-lockfile --cache-folder /tmp/cache + - yarn --cwd ./web/source ts-patch install # https://typia.io/docs/setup/#manual-setup - name: web-lint image: node:18-alpine @@ -191,6 +192,6 @@ steps: --- kind: signature -hmac: c3efbd528a76016562f88ae435141cfb5fd6d4d07b6ad2a24ecc23cb529cc1c6 +hmac: d7b93470276a0df7e4d862941489f00da107df3d085200009b776d33599e6043 ... diff --git a/.goreleaser.yml b/.goreleaser.yml index 1b49136c7..a49bb32e8 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -8,6 +8,7 @@ before: - sed -i "s/REPLACE_ME/{{ incpatch .Version }}/" web/assets/swagger.yaml # Install web deps + bundle web assets - yarn --cwd ./web/source install + - yarn --cwd ./web/source ts-patch install # https://typia.io/docs/setup/#manual-setup - yarn --cwd ./web/source build builds: # https://goreleaser.com/customization/build/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c8218564d..628832e1c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -229,13 +229,15 @@ Using [NVM](https://github.com/nvm-sh/nvm) is one convenient way to install them To install frontend dependencies: ```bash -yarn --cwd web/source +yarn --cwd ./web/source install && yarn --cwd ./web/source ts-patch install ``` +The `ts-patch` step is necessary because of Typia, which we use for some type validation: see [Typia install docs](https://typia.io/docs/setup/#manual-setup). + To recompile frontend bundles into `web/assets/dist`: ```bash -yarn --cwd web/source build +yarn --cwd ./web/source build ``` #### Live Reloading diff --git a/Dockerfile b/Dockerfile index d772f7497..7c1cce4d2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,7 @@ FROM --platform=${BUILDPLATFORM} node:18-alpine AS bundler COPY web web RUN yarn --cwd ./web/source install && \ + yarn --cwd ./web/source ts-patch install && \ yarn --cwd ./web/source build && \ rm -rf ./web/source diff --git a/internal/api/client/admin/domainpermission.go b/internal/api/client/admin/domainpermission.go index bd6b83425..203eddc8b 100644 --- a/internal/api/client/admin/domainpermission.go +++ b/internal/api/client/admin/domainpermission.go @@ -95,7 +95,7 @@ func (m *Module) createDomainPermissions( if importing && form.Domains.Size == 0 { err = errors.New("import was specified but list of domains is empty") - } else if form.Domain == "" { + } else if !importing && form.Domain == "" { err = errors.New("empty domain provided") } diff --git a/web/source/package.json b/web/source/package.json index d3c1cbe2b..20f525228 100644 --- a/web/source/package.json +++ b/web/source/package.json @@ -45,6 +45,10 @@ "@browserify/envify": "^6.0.0", "@browserify/uglifyify": "^6.0.0", "@joepie91/eslint-config": "^1.1.1", + "@types/bluebird": "^3.5.39", + "@types/is-valid-domain": "^0.0.2", + "@types/papaparse": "^5.3.9", + "@types/psl": "^1.1.1", "@types/react-dom": "^18.2.8", "@typescript-eslint/eslint-plugin": "^6.7.4", "@typescript-eslint/parser": "^6.7.4", @@ -63,7 +67,10 @@ "postcss-nested": "^6.0.0", "source-map-loader": "^4.0.1", "ts-loader": "^9.4.4", + "ts-node": "^10.9.1", + "ts-patch": "^3.0.2", "tsify": "^5.0.4", - "typescript": "^5.2.2" + "typescript": "^5.2.2", + "typia": "^5.1.6" } } 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"); diff --git a/web/source/tsconfig.json b/web/source/tsconfig.json index 2f85c03b2..f8720e2b6 100644 --- a/web/source/tsconfig.json +++ b/web/source/tsconfig.json @@ -84,7 +84,7 @@ /* Type Checking */ "strict": false, /* Enable all strict type-checking options. */ // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ - // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ @@ -104,6 +104,10 @@ /* Completeness */ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ - "skipLibCheck": true /* Skip type checking all .d.ts files. */ + "skipLibCheck": true, /* Skip type checking all .d.ts files. */ + + "plugins": [ + { "transform": "typia/lib/transform" } + ] } } diff --git a/web/source/yarn.lock b/web/source/yarn.lock index bce379dac..b9ff73bb5 100644 --- a/web/source/yarn.lock +++ b/web/source/yarn.lock @@ -1040,6 +1040,13 @@ through2 "^4.0.2" xtend "^4.0.1" +"@cspotcode/source-map-support@^0.8.0": + version "0.8.1" + resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1" + integrity sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw== + dependencies: + "@jridgewell/trace-mapping" "0.3.9" + "@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0": version "4.4.0" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59" @@ -1132,7 +1139,7 @@ "@jridgewell/sourcemap-codec" "^1.4.10" "@jridgewell/trace-mapping" "^0.3.9" -"@jridgewell/resolve-uri@^3.1.0": +"@jridgewell/resolve-uri@^3.0.3", "@jridgewell/resolve-uri@^3.1.0": version "3.1.1" resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz#c08679063f279615a3326583ba3a90d1d82cc721" integrity sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA== @@ -1155,6 +1162,14 @@ resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32" integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg== +"@jridgewell/trace-mapping@0.3.9": + version "0.3.9" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz#6534fd5933a53ba7cbf3a17615e273a0d1273ff9" + integrity sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ== + dependencies: + "@jridgewell/resolve-uri" "^3.0.3" + "@jridgewell/sourcemap-codec" "^1.4.10" + "@jridgewell/trace-mapping@^0.3.17", "@jridgewell/trace-mapping@^0.3.9": version "0.3.19" resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.19.tgz#f8a3249862f91be48d3127c3cfe992f79b4b8811" @@ -1194,6 +1209,31 @@ redux-thunk "^2.4.2" reselect "^4.1.8" +"@tsconfig/node10@^1.0.7": + version "1.0.9" + resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.9.tgz#df4907fc07a886922637b15e02d4cebc4c0021b2" + integrity sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA== + +"@tsconfig/node12@^1.0.7": + version "1.0.11" + resolved "https://registry.yarnpkg.com/@tsconfig/node12/-/node12-1.0.11.tgz#ee3def1f27d9ed66dac6e46a295cffb0152e058d" + integrity sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag== + +"@tsconfig/node14@^1.0.0": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@tsconfig/node14/-/node14-1.0.3.tgz#e4386316284f00b98435bf40f72f75a09dabf6c1" + integrity sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow== + +"@tsconfig/node16@^1.0.2": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.4.tgz#0b92dcc0cc1c81f6f306a381f28e31b1a56536e9" + integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA== + +"@types/bluebird@^3.5.39": + version "3.5.39" + resolved "https://registry.yarnpkg.com/@types/bluebird/-/bluebird-3.5.39.tgz#6aaf8bcbf005bb091d06ddaa0f620be078bf6a73" + integrity sha512-0h2lKudcFwHih8NHAgt/uyAIUQDO0AdfJYlWBXD8r+gFDulUi2CMZoQSh2Q5ol1FMaHV9k7/4HtcbA8ABtexmA== + "@types/hoist-non-react-statics@^3.3.1": version "3.3.2" resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#dc1e9ded53375d37603c479cc12c693b0878aa2a" @@ -1209,6 +1249,11 @@ dependencies: "@types/node" "*" +"@types/is-valid-domain@^0.0.2": + version "0.0.2" + resolved "https://registry.yarnpkg.com/@types/is-valid-domain/-/is-valid-domain-0.0.2.tgz#78b236f05da281213481c4af0a7ce452d4ff810a" + integrity sha512-18CgqfDjh0m+GFfekGz1q3g32XESx7vutfBFnPkIdpDtuvgvOac8lrghRiw3SLI19vNa/XdPKIhL6CQpFMIDug== + "@types/json-schema@^7.0.12": version "7.0.13" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.13.tgz#02c24f4363176d2d18fc8b70b9f3c54aba178a85" @@ -1219,11 +1264,23 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-20.8.2.tgz#d76fb80d87d0d8abfe334fc6d292e83e5524efc4" integrity sha512-Vvycsc9FQdwhxE3y3DzeIxuEJbWGDsnrxvMADzTDF/lcdR9/K+AQIeAghTQsHtotg/q0j3WEOYS/jQgSdWue3w== +"@types/papaparse@^5.3.9": + version "5.3.9" + resolved "https://registry.yarnpkg.com/@types/papaparse/-/papaparse-5.3.9.tgz#5f955949eae512c1eec70bba4bfeb2e7f4396564" + integrity sha512-sZcrKD63qA4/6GyBcVvX6AIp0AkpfyYk00CUQHMBvb4+OVXTZWyXUvidUZaai1wyKUVyJoxO7mgREam/pMRrDw== + dependencies: + "@types/node" "*" + "@types/prop-types@*": version "15.7.8" resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.8.tgz#805eae6e8f41bd19e88917d2ea200dc992f405d3" integrity sha512-kMpQpfZKSCBqltAJwskgePRaYRFukDkm1oItcAbC3gNELR20XIBcN9VRgg4+m8DKsTfkWeA4m4Imp4DDuWy7FQ== +"@types/psl@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@types/psl/-/psl-1.1.1.tgz#3ba9e6d4bd2a32652a639fd5df7e539151d0a3b2" + integrity sha512-nHPbucWhAfVSuJ+xVc4AjjtM/y6U/eLHeXxyjzPHzKVr+j8uHvGg2wlXjmReSE2p851ltEWKGNQOtBK0beF/Eg== + "@types/react-dom@^18.2.8": version "18.2.10" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.10.tgz#06247cb600e39b63a0a385f6a5014c44bab296f2" @@ -1781,12 +1838,17 @@ acorn-walk@^7.0.0: resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.2.0.tgz#0de889a601203909b0fbe07b8938dc21d2e967bc" integrity sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA== +acorn-walk@^8.1.1: + version "8.2.0" + resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.2.0.tgz#741210f2e2426454508853a2f44d0ab83b7f69c1" + integrity sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA== + acorn@^7.0.0: version "7.4.1" resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== -acorn@^8.8.2, acorn@^8.9.0: +acorn@^8.4.1, acorn@^8.8.2, acorn@^8.9.0: version "8.10.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.10.0.tgz#8be5b3907a67221a81ab23c7889c4c5526b62ec5" integrity sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw== @@ -1806,6 +1868,13 @@ amdefine@>=0.0.4: resolved "https://registry.yarnpkg.com/amdefine/-/amdefine-1.0.1.tgz#4a5282ac164729e93619bcfd3ad151f817ce91f5" integrity sha512-S2Hw0TtNkMJhIabBwIojKL9YHO5T0n5eNqWJ7Lrlel/zDbftQpxpapi8tZs3X1HWa+u+QeydGmzzNU0m09+Rcg== +ansi-escapes@^4.2.1: + version "4.3.2" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e" + integrity sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ== + dependencies: + type-fest "^0.21.3" + ansi-regex@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" @@ -1818,7 +1887,7 @@ ansi-styles@^3.2.1: dependencies: color-convert "^1.9.0" -ansi-styles@^4.1.0: +ansi-styles@^4.0.0, ansi-styles@^4.1.0: version "4.3.0" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== @@ -1838,6 +1907,11 @@ anymatch@^3.1.0, anymatch@~3.1.2: normalize-path "^3.0.0" picomatch "^2.0.4" +arg@^4.1.0: + version "4.1.3" + resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" + integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== + argparse@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" @@ -1893,6 +1967,11 @@ array-includes@^3.1.6: get-intrinsic "^1.2.1" is-string "^1.0.7" +array-timsort@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/array-timsort/-/array-timsort-1.0.3.tgz#3c9e4199e54fb2b9c3fe5976396a21614ef0d926" + integrity sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ== + array-union@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d" @@ -2043,7 +2122,7 @@ binary-extensions@^2.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== -bl@^4.0.0, bl@^4.0.2: +bl@^4.0.0, bl@^4.0.2, bl@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a" integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w== @@ -2343,7 +2422,7 @@ chalk@^2.4.2: escape-string-regexp "^1.0.5" supports-color "^5.3.0" -chalk@^4.0.0, chalk@^4.1.0: +chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.1, chalk@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== @@ -2351,6 +2430,11 @@ chalk@^4.0.0, chalk@^4.1.0: ansi-styles "^4.1.0" supports-color "^7.1.0" +chardet@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" + integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== + chokidar@^3.4.0: version "3.5.3" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" @@ -2374,6 +2458,23 @@ cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: inherits "^2.0.1" safe-buffer "^5.0.1" +cli-cursor@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307" + integrity sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw== + dependencies: + restore-cursor "^3.1.0" + +cli-spinners@^2.5.0: + version "2.9.1" + resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.9.1.tgz#9c0b9dad69a6d47cbb4333c14319b060ed395a35" + integrity sha512-jHgecW0pxkonBJdrKsqxgRX9AcG+u/5k0Q7WPDfi8AogLAdwxEkyYYNWwZ5GvVFoFx2uiY1eNcSK00fh+1+FyQ== + +cli-width@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-3.0.0.tgz#a2f48437a2caa9a22436e794bf071ec9e61cedf6" + integrity sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw== + clone-regexp@^2.1.0: version "2.2.0" resolved "https://registry.yarnpkg.com/clone-regexp/-/clone-regexp-2.2.0.tgz#7d65e00885cd8796405c35a737e7a86b7429e36f" @@ -2381,6 +2482,11 @@ clone-regexp@^2.1.0: dependencies: is-regexp "^2.0.0" +clone@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e" + integrity sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg== + color-convert@^1.9.0: version "1.9.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" @@ -2425,11 +2531,27 @@ combine-source-map@~0.6.1: lodash.memoize "~3.0.3" source-map "~0.4.2" +commander@^10.0.0: + version "10.0.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06" + integrity sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug== + commander@^2.20.0: version "2.20.3" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== +comment-json@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/comment-json/-/comment-json-4.2.3.tgz#50b487ebbf43abe44431f575ebda07d30d015365" + integrity sha512-SsxdiOf064DWoZLH799Ata6u7iV658A11PlWtZATDlXPpKGJnbJZ5Z24ybixAi+LUUqJ/GKowAejtC5GFUG7Tw== + dependencies: + array-timsort "^1.0.3" + core-util-is "^1.0.3" + esprima "^4.0.1" + has-own-prop "^2.0.0" + repeat-string "^1.6.1" + concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -2509,7 +2631,7 @@ core-js@^3.26.1: resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.33.0.tgz#70366dbf737134761edb017990cf5ce6c6369c40" integrity sha512-HoZr92+ZjFEKar5HS6MC776gYslNOKHt75mEBKWKnPeFDpZ6nH5OeF3S6HFT1mUAUZKrzkez05VboaX8myjSuw== -core-util-is@~1.0.0: +core-util-is@^1.0.3, core-util-is@~1.0.0: version "1.0.3" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== @@ -2550,6 +2672,11 @@ create-hmac@^1.1.0, create-hmac@^1.1.4, create-hmac@^1.1.7: safe-buffer "^5.0.1" sha.js "^2.4.8" +create-require@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" + integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== + cross-spawn@^7.0.2: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" @@ -2658,6 +2785,13 @@ default-value@^1.0.0: dependencies: es6-promise-try "0.0.1" +defaults@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/defaults/-/defaults-1.0.4.tgz#b0b02062c1e2aa62ff5d9528f0f98baa90978d7a" + integrity sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A== + dependencies: + clone "^1.0.2" + define-data-property@^1.0.1: version "1.1.0" resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.0.tgz#0db13540704e1d8d479a0656cf781267531b9451" @@ -2732,6 +2866,11 @@ detective@^5.2.0: defined "^1.0.0" minimist "^1.2.6" +diff@^4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" + integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== + diffie-hellman@^5.0.0: version "5.0.3" resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.3.tgz#40e8ee98f55a2149607146921c63e1ae5f3d2875" @@ -2767,6 +2906,11 @@ domain-browser@^1.2.0: resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda" integrity sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA== +drange@^1.0.2: + version "1.1.1" + resolved "https://registry.yarnpkg.com/drange/-/drange-1.1.1.tgz#b2aecec2aab82fcef11dbbd7b9e32b83f8f6c0b8" + integrity sha512-pYxfDYpued//QpnLIm4Avk7rsNtAtQkUES2cwAYSvD/wd2pKD71gN2Ebj3e7klzXwjocvE8c5vx/1fxwpqmSxA== + duplexer2@^0.1.2, duplexer2@~0.1.0, duplexer2@~0.1.2, duplexer2@~0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1" @@ -2802,6 +2946,11 @@ elliptic@^6.5.3: minimalistic-assert "^1.0.1" minimalistic-crypto-utils "^1.0.1" +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + emojis-list@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78" @@ -3231,6 +3380,15 @@ ext@^1.1.2: dependencies: type "^2.7.2" +external-editor@^3.0.3: + version "3.1.0" + resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-3.1.0.tgz#cb03f740befae03ea4d283caed2741a83f335495" + integrity sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew== + dependencies: + chardet "^0.7.0" + iconv-lite "^0.4.24" + tmp "^0.0.33" + factor-bundle@^2.5.0: version "2.5.0" resolved "https://registry.yarnpkg.com/factor-bundle/-/factor-bundle-2.5.0.tgz#8ea8957da39d7586283cc3ee353cd9911a45e779" @@ -3296,6 +3454,13 @@ faye-websocket@^0.11.3: dependencies: websocket-driver ">=0.5.1" +figures@^3.0.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/figures/-/figures-3.2.0.tgz#625c18bd293c604dc4a8ddb2febf0c88341746af" + integrity sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg== + dependencies: + escape-string-regexp "^1.0.5" + file-entry-cache@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" @@ -3503,6 +3668,15 @@ glob@^7.1.0, glob@^7.1.3: once "^1.3.0" path-is-absolute "^1.0.0" +global-prefix@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-3.0.0.tgz#fc85f73064df69f50421f47f883fe5b913ba9b97" + integrity sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg== + dependencies: + ini "^1.3.5" + kind-of "^6.0.2" + which "^1.3.1" + globals@^11.1.0: version "11.12.0" resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" @@ -3566,6 +3740,11 @@ has-flag@^4.0.0: resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== +has-own-prop@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/has-own-prop/-/has-own-prop-2.0.0.tgz#f0f95d58f65804f5d218db32563bb85b8e0417af" + integrity sha512-Pq0h+hvsVm6dDEa8x82GnLSYHOzNDt7f0ddFa3FqcQlgzEiptPqL+XrOJNavjOzSYiYWIrgeVYYgGlLmnxwilQ== + has-property-descriptors@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz#610708600606d36961ed04c196193b6a607fa861" @@ -3674,7 +3853,7 @@ https-browserify@^1.0.0: resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73" integrity sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg== -iconv-lite@0.4.24: +iconv-lite@0.4.24, iconv-lite@^0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== @@ -3779,6 +3958,11 @@ inherits@2.0.3: resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" integrity sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw== +ini@^1.3.5: + version "1.3.8" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" + integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== + inject-lr-script@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/inject-lr-script/-/inject-lr-script-2.2.0.tgz#58d91cd99e5de1a3f172aa076f7db8651ee72db2" @@ -3800,6 +3984,27 @@ inline-source-map@~0.6.0: dependencies: source-map "~0.5.3" +inquirer@^8.2.5: + version "8.2.6" + resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-8.2.6.tgz#733b74888195d8d400a67ac332011b5fae5ea562" + integrity sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg== + dependencies: + ansi-escapes "^4.2.1" + chalk "^4.1.1" + cli-cursor "^3.1.0" + cli-width "^3.0.0" + external-editor "^3.0.3" + figures "^3.0.0" + lodash "^4.17.21" + mute-stream "0.0.8" + ora "^5.4.1" + run-async "^2.4.0" + rxjs "^7.5.5" + string-width "^4.1.0" + strip-ansi "^6.0.0" + through "^2.3.6" + wrap-ansi "^6.0.1" + insert-css@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/insert-css/-/insert-css-2.0.0.tgz#eb5d1097b7542f4c79ea3060d3aee07d053880f4" @@ -3922,6 +4127,11 @@ is-finalizationregistry@^1.0.2: dependencies: call-bind "^1.0.2" +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + is-generator-function@^1.0.10, is-generator-function@^1.0.7: version "1.0.10" resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.0.10.tgz#f1558baf1ac17e0deea7c0415c438351ff2b3c72" @@ -3936,6 +4146,11 @@ is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: dependencies: is-extglob "^2.1.1" +is-interactive@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-interactive/-/is-interactive-1.0.0.tgz#cea6e6ae5c870a7b0a0004070b7b587e0252912e" + integrity sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w== + is-map@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.2.tgz#00922db8c9bf73e81b7a335827bc2a43f2b91127" @@ -4024,6 +4239,11 @@ is-typed-array@^1.1.10, is-typed-array@^1.1.12, is-typed-array@^1.1.3, is-typed- dependencies: which-typed-array "^1.1.11" +is-unicode-supported@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" + integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== + is-utf8@^0.2.0: version "0.2.1" resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72" @@ -4168,6 +4388,11 @@ keyv@^4.5.3: dependencies: json-buffer "3.0.1" +kind-of@^6.0.2: + version "6.0.3" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" + integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== + labeled-stream-splicer@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/labeled-stream-splicer/-/labeled-stream-splicer-1.0.2.tgz#4615331537784981e8fd264e1f3a434c4e0ddd65" @@ -4252,6 +4477,19 @@ lodash.merge@^4.6.2: resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== +lodash@^4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + +log-symbols@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503" + integrity sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg== + dependencies: + chalk "^4.1.0" + is-unicode-supported "^0.1.0" + loose-envify@^1.1.0, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" @@ -4280,6 +4518,11 @@ magic-string@0.25.1: dependencies: sourcemap-codec "^1.4.1" +make-error@^1.1.1: + version "1.3.6" + resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" + integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== + map-obj@^4.1.0: version "4.3.0" resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-4.3.0.tgz#9304f906e93faae70880da102a9f1df0ea8bb05a" @@ -4369,6 +4612,11 @@ mime@1.6.0: resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== +mimic-fn@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" + integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== + minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" @@ -4391,7 +4639,7 @@ minimist@0.0.5: resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.5.tgz#d7aa327bcecf518f9106ac6b8f003fa3bcea8566" integrity sha512-rSJ0cdmCj3qmKdObcnMcWgPVOyaOWlazLhZAJW0s6G6lx1ZEuFkraWmEH5LTvX90btkfHPclQBjvjU7A/kYRFg== -minimist@^1.1.0, minimist@^1.2.0, minimist@^1.2.6: +minimist@^1.1.0, minimist@^1.2.0, minimist@^1.2.6, minimist@^1.2.8: version "1.2.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== @@ -4452,6 +4700,11 @@ multisplice@^1.0.0: resolved "https://registry.yarnpkg.com/multisplice/-/multisplice-1.0.0.tgz#e74cf2948dcb51a6c317fc5e22980a652f7830e9" integrity sha512-KU5tVjIdTGsMb92JlWwEZCGrvtI1ku9G9GuNbWdQT/Ici1ztFXX0L8lWpbbC3pISVMfBNL56wdqplHvva2XSlA== +mute-stream@0.0.8: + version "0.0.8" + resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" + integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== + nanoid@^3.3.6: version "3.3.6" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c" @@ -4576,6 +4829,13 @@ once@^1.3.0: dependencies: wrappy "1" +onetime@^5.1.0: + version "5.1.2" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" + integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== + dependencies: + mimic-fn "^2.1.0" + optionator@^0.8.1: version "0.8.3" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495" @@ -4600,11 +4860,31 @@ optionator@^0.9.3: prelude-ls "^1.2.1" type-check "^0.4.0" +ora@^5.4.1: + version "5.4.1" + resolved "https://registry.yarnpkg.com/ora/-/ora-5.4.1.tgz#1b2678426af4ac4a509008e5e4ac9e9959db9e18" + integrity sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ== + dependencies: + bl "^4.1.0" + chalk "^4.1.0" + cli-cursor "^3.1.0" + cli-spinners "^2.5.0" + is-interactive "^1.0.0" + is-unicode-supported "^0.1.0" + log-symbols "^4.1.0" + strip-ansi "^6.0.0" + wcwidth "^1.0.1" + os-browserify@~0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.3.0.tgz#854373c7f5c2315914fc9bfc6bd8238fdda1ec27" integrity sha512-gjcpUc3clBf9+210TRaDWbf+rZZZEshZ+DlXMRCeAjp0xhTrnQsKHypIy1J3d5hKdUzj69t708EHtU8P6bUn0A== +os-tmpdir@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" + integrity sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g== + outpipe@^1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/outpipe/-/outpipe-1.1.1.tgz#50cf8616365e87e031e29a5ec9339a3da4725fa2" @@ -4936,6 +5216,14 @@ queue-microtask@^1.2.2: resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== +randexp@^0.5.3: + version "0.5.3" + resolved "https://registry.yarnpkg.com/randexp/-/randexp-0.5.3.tgz#f31c2de3148b30bdeb84b7c3f59b0ebb9fec3738" + integrity sha512-U+5l2KrcMNOUPYvazA3h5ekF80FHTUG+87SEAmHZmolh1M+i/WyTCxVzmi+tidIa1tM4BSe8g2Y/D3loWDjj+w== + dependencies: + drange "^1.0.2" + ret "^0.2.0" + randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5: version "2.1.0" resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" @@ -5172,6 +5460,11 @@ remove-accents@0.4.2: resolved "https://registry.yarnpkg.com/remove-accents/-/remove-accents-0.4.2.tgz#0a43d3aaae1e80db919e07ae254b285d9e1c7bb5" integrity sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA== +repeat-string@^1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" + integrity sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w== + requireindex@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/requireindex/-/requireindex-1.2.0.tgz#3463cdb22ee151902635aa6c9535d4de9c2ef1ef" @@ -5192,7 +5485,7 @@ resolve-from@^4.0.0: resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== -resolve@^1.1.4, resolve@^1.1.7, resolve@^1.14.2, resolve@^1.17.0, resolve@^1.4.0: +resolve@^1.1.4, resolve@^1.1.7, resolve@^1.14.2, resolve@^1.17.0, resolve@^1.22.2, resolve@^1.4.0: version "1.22.6" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.6.tgz#dd209739eca3aef739c626fea1b4f3c506195362" integrity sha512-njhxM7mV12JfufShqGy3Rz8j11RPdLy4xi15UurGJeoHLfJpVXKdh3ueuOqbYUcDZnffr6X739JBo5LzyahEsw== @@ -5218,6 +5511,19 @@ resp-modifier@^6.0.0: debug "^2.2.0" minimatch "^3.0.2" +restore-cursor@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-3.1.0.tgz#39f67c54b3a7a58cea5236d95cf0034239631f7e" + integrity sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA== + dependencies: + onetime "^5.1.0" + signal-exit "^3.0.2" + +ret@^0.2.0: + version "0.2.2" + resolved "https://registry.yarnpkg.com/ret/-/ret-0.2.2.tgz#b6861782a1f4762dce43402a71eb7a283f44573c" + integrity sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ== + reusify@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" @@ -5243,6 +5549,11 @@ ripemd160@^2.0.0, ripemd160@^2.0.1: hash-base "^3.0.0" inherits "^2.0.1" +run-async@^2.4.0: + version "2.4.1" + resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455" + integrity sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ== + run-parallel@^1.1.9: version "1.2.0" resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" @@ -5250,6 +5561,13 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" +rxjs@^7.5.5: + version "7.8.1" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.1.tgz#6f6f3d99ea8044291efd92e7c7fcf562c4057543" + integrity sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg== + dependencies: + tslib "^2.1.0" + safe-array-concat@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/safe-array-concat/-/safe-array-concat-1.0.1.tgz#91686a63ce3adbea14d61b14c99572a8ff84754c" @@ -5314,7 +5632,7 @@ semver@^6.1.0, semver@^6.3.1: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.3.4, semver@^7.5.4: +semver@^7.3.4, semver@^7.3.8, semver@^7.5.4: version "7.5.4" resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e" integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA== @@ -5410,6 +5728,11 @@ side-channel@^1.0.4: get-intrinsic "^1.0.2" object-inspect "^1.9.0" +signal-exit@^3.0.2: + version "3.0.7" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" + integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== + simple-concat@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/simple-concat/-/simple-concat-1.0.1.tgz#f46976082ba35c2263f1c8ab5edfe26c41c9552f" @@ -5614,6 +5937,15 @@ string-template@~0.2.1: resolved "https://registry.yarnpkg.com/string-template/-/string-template-0.2.1.tgz#42932e598a352d01fc22ec3367d9d84eec6c9add" integrity sha512-Yptehjogou2xm4UJbxJ4CxgZx12HBfeystp0y3x7s4Dj32ltVVG1Gg8YhKjHZkHicuKpZX/ffilA8505VbUbpw== +string-width@^4.1.0: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + string.prototype.matchall@^4.0.8: version "4.0.10" resolved "https://registry.yarnpkg.com/string.prototype.matchall/-/string.prototype.matchall-4.0.10.tgz#a1553eb532221d4180c51581d6072cd65d1ee100" @@ -5675,7 +6007,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -strip-ansi@^6.0.1: +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -5790,7 +6122,7 @@ through2@^4.0.2: dependencies: readable-stream "3" -"through@>=2.2.7 <3", through@~2.3.4: +"through@>=2.2.7 <3", through@^2.3.6, through@~2.3.4: version "2.3.8" resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg== @@ -5814,6 +6146,13 @@ tiny-lr@^2.0.0: object-assign "^4.1.0" qs "^6.4.0" +tmp@^0.0.33: + version "0.0.33" + resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" + integrity sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw== + dependencies: + os-tmpdir "~1.0.2" + to-fast-properties@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" @@ -5846,6 +6185,37 @@ ts-loader@^9.4.4: micromatch "^4.0.0" semver "^7.3.4" +ts-node@^10.9.1: + version "10.9.1" + resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.1.tgz#e73de9102958af9e1f0b168a6ff320e25adcff4b" + integrity sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw== + dependencies: + "@cspotcode/source-map-support" "^0.8.0" + "@tsconfig/node10" "^1.0.7" + "@tsconfig/node12" "^1.0.7" + "@tsconfig/node14" "^1.0.0" + "@tsconfig/node16" "^1.0.2" + acorn "^8.4.1" + acorn-walk "^8.1.1" + arg "^4.1.0" + create-require "^1.1.0" + diff "^4.0.1" + make-error "^1.1.1" + v8-compile-cache-lib "^3.0.1" + yn "3.1.1" + +ts-patch@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/ts-patch/-/ts-patch-3.0.2.tgz#cbdf88e4dfb596e4dab8f2c8269361d33270a0ba" + integrity sha512-iTg8euqiNsNM1VDfOsVIsP0bM4kAVXU38n7TGQSkky7YQX/syh6sDPIRkvSS0HjT8ZOr0pq1h+5Le6jdB3hiJQ== + dependencies: + chalk "^4.1.2" + global-prefix "^3.0.0" + minimist "^1.2.8" + resolve "^1.22.2" + semver "^7.3.8" + strip-ansi "^6.0.1" + tsconfig@^5.0.3: version "5.0.3" resolved "https://registry.yarnpkg.com/tsconfig/-/tsconfig-5.0.3.tgz#5f4278e701800967a8fc383fd19648878f2a6e3a" @@ -5868,6 +6238,11 @@ tsify@^5.0.4: through2 "^2.0.0" tsconfig "^5.0.3" +tslib@^2.1.0: + version "2.6.2" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" + integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== + tty-browserify@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.1.tgz#3f05251ee17904dfd0677546670db9651682b811" @@ -5892,6 +6267,11 @@ type-fest@^0.20.2: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== +type-fest@^0.21.3: + version "0.21.3" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37" + integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== + type-is@~1.6.18: version "1.6.18" resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" @@ -5959,6 +6339,16 @@ typescript@^5.2.2: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.2.2.tgz#5ebb5e5a5b75f085f22bc3f8460fba308310fa78" integrity sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w== +typia@^5.1.6: + version "5.1.6" + resolved "https://registry.yarnpkg.com/typia/-/typia-5.1.6.tgz#ee380512ee737bd704ddb1e3ef792b0a16f61639" + integrity sha512-in/m6hhsoS4jDfztT/hMlWVS670+0BcQNR0AX/sVctqrY/VnVs8cNdJiFn8iZdQ/QvLqWaT/FW1WUuibn8prMw== + dependencies: + commander "^10.0.0" + comment-json "^4.2.3" + inquirer "^8.2.5" + randexp "^0.5.3" + umd@^3.0.0: version "3.0.3" resolved "https://registry.yarnpkg.com/umd/-/umd-3.0.3.tgz#aa9fe653c42b9097678489c01000acb69f0b26cf" @@ -6074,6 +6464,11 @@ utils-merge@1.0.1: resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== +v8-compile-cache-lib@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" + integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg== + validatem-as-array-of@^0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/validatem-as-array-of/-/validatem-as-array-of-0.0.1.tgz#08ea8f5bd813bdffa703095f406290095b8bfd5a" @@ -6107,6 +6502,13 @@ watchify@^4.0.0: through2 "^4.0.2" xtend "^4.0.2" +wcwidth@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/wcwidth/-/wcwidth-1.0.1.tgz#f0b0dcf915bc5ff1528afadb2c0e17b532da2fe8" + integrity sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg== + dependencies: + defaults "^1.0.3" + websocket-driver@>=0.5.1: version "0.7.4" resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.7.4.tgz#89ad5295bbf64b480abcba31e4953aca706f5760" @@ -6171,6 +6573,13 @@ which-typed-array@^1.1.11, which-typed-array@^1.1.2, which-typed-array@^1.1.9: gopd "^1.0.1" has-tostringtag "^1.0.0" +which@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" + integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== + dependencies: + isexe "^2.0.0" + which@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" @@ -6190,6 +6599,15 @@ wouter@^2.8.0-alpha.2: dependencies: use-sync-external-store "^1.0.0" +wrap-ansi@^6.0.1: + version "6.2.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" + integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrappy@1: version "1.0.2" resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" @@ -6215,6 +6633,11 @@ yallist@^4.0.0: resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== +yn@3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50" + integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q== + yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" |