diff options
Diffstat (limited to 'web/source/settings/lib')
37 files changed, 2714 insertions, 1087 deletions
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/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/lib/query/admin/reports.js b/web/source/settings/lib/types/custom-emoji.ts index 1c45bb7bc..f54e9e2a0 100644 --- a/web/source/settings/lib/query/admin/reports.js +++ b/web/source/settings/lib/types/custom-emoji.ts @@ -17,35 +17,33 @@ 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"] - }), +export interface CustomEmoji { + id?: string; + shortcode: string; + category?: string; +} - getReport: build.query({ - query: (id) => ({ - url: `/api/v1/admin/reports/${id}` - }), - providesTags: (res, error, id) => [{ type: "Reports", id }] - }), +/** + * Query parameters for GET to /api/v1/admin/custom_emojis. + */ +export interface ListEmojiParams { - 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 +} + +/** + * 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 |