diff options
Diffstat (limited to 'web/source/settings/views')
19 files changed, 1301 insertions, 178 deletions
diff --git a/web/source/settings/views/admin/actions/keys/expireremote.tsx b/web/source/settings/views/admin/actions/keys/expireremote.tsx index 1d62f9439..082f1fdff 100644 --- a/web/source/settings/views/admin/actions/keys/expireremote.tsx +++ b/web/source/settings/views/admin/actions/keys/expireremote.tsx @@ -22,32 +22,11 @@ import { TextInput } from "../../../../components/form/inputs";  import MutationButton from "../../../../components/form/mutation-button";  import { useTextInput } from "../../../../lib/form";  import { useInstanceKeysExpireMutation } from "../../../../lib/query/admin/actions"; -import isValidDomain from "is-valid-domain"; +import { formDomainValidator } from "../../../../lib/util/formvalidators";  export default function ExpireRemote({}) {  	const domainField = useTextInput("domain", { -		validator: (v: string) => { -			if (v.length === 0) { -				return ""; -			} - -			if (v[v.length-1] === ".") { -				return "invalid domain"; -			} - -			const valid = isValidDomain(v, { -				subdomain: true, -				wildcard: false, -				allowUnicode: true, -				topLevel: false, -			}); - -			if (valid) { -				return ""; -			} - -			return "invalid domain"; -		} +		validator: formDomainValidator,  	});  	const [expire, expireResult] = useInstanceKeysExpireMutation(); diff --git a/web/source/settings/views/admin/http-header-permissions/detail.tsx b/web/source/settings/views/admin/http-header-permissions/detail.tsx index 522f2dba2..e0d49ffd2 100644 --- a/web/source/settings/views/admin/http-header-permissions/detail.tsx +++ b/web/source/settings/views/admin/http-header-permissions/detail.tsx @@ -17,7 +17,7 @@  	along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -import React, { useEffect, useMemo } from "react"; +import React, { useMemo } from "react";  import { useLocation, useParams } from "wouter";  import { PermType } from "../../../lib/types/perm";  import { useDeleteHeaderAllowMutation, useDeleteHeaderBlockMutation, useGetHeaderAllowQuery, useGetHeaderBlockQuery } from "../../../lib/query/admin/http-header-permissions"; @@ -26,8 +26,7 @@ import { FetchBaseQueryError } from "@reduxjs/toolkit/query";  import { SerializedError } from "@reduxjs/toolkit";  import Loading from "../../../components/loading";  import { Error } from "../../../components/error"; -import { useLazyGetAccountQuery } from "../../../lib/query/admin"; -import Username from "../../../components/username"; +import UsernameLozenge from "../../../components/username-lozenge";  import { useBaseUrl } from "../../../lib/navigation/util";  import BackButton from "../../../components/back-button";  import MutationButton from "../../../components/form/mutation-button"; @@ -92,58 +91,19 @@ interface PermDeetsProps {  function PermDeets({  	permType,  	data: perm, -	isLoading: isLoadingPerm, -	isFetching: isFetchingPerm, -	isError: isErrorPerm, -	error: errorPerm, +	isLoading, +	isFetching, +	isError, +	error,  }: PermDeetsProps) {  	const [ location ] = useLocation();  	const baseUrl = useBaseUrl(); -	 -	// Once we've loaded the perm, trigger -	// getting the account that created it. -	const [ getAccount, getAccountRes ] = useLazyGetAccountQuery(); -	useEffect(() => { -		if (!perm) { -			return; -		} -		getAccount(perm.created_by, true); -	}, [getAccount, perm]); - -	// Load the createdByAccount if possible, -	// returning a username lozenge with -	// a link to the account. -	const createdByAccount = useMemo(() => { -		const { -			data: account, -			isLoading: isLoadingAccount, -			isFetching: isFetchingAccount, -			isError: isErrorAccount, -		} = getAccountRes; -		 -		// Wait for query to finish, returning -		// loading spinner in the meantime. -		if (isLoadingAccount || isFetchingAccount || !perm) { -			return <Loading />; -		} else if (isErrorAccount || account === undefined) { -			// Fall back to account ID. -			return perm?.created_by; -		} - -		return ( -			<Username -				account={account} -				linkTo={`~/settings/moderation/accounts/${account.id}`} -				backLocation={`~${baseUrl}${location}`} -			/> -		); -	}, [getAccountRes, perm, baseUrl, location]); - -	// Now wait til the perm itself is loaded. -	if (isLoadingPerm || isFetchingPerm) { + +	// Wait til the perm itself is loaded. +	if (isLoading || isFetching) {  		return <Loading />; -	} else if (isErrorPerm) { -		return <Error error={errorPerm} />; +	} else if (isError) { +		return <Error error={error} />;  	} else if (perm === undefined) {  		throw "perm undefined";  	} @@ -172,7 +132,13 @@ function PermDeets({  				</div>  				<div className="info-list-entry">  					<dt>Created By</dt> -					<dd>{createdByAccount}</dd> +					<dd> +						<UsernameLozenge +							account={perm.created_by} +							linkTo={`~/settings/moderation/accounts/${perm.created_by}`} +							backLocation={`~${baseUrl}${location}`} +						/> +					</dd>  				</div>  				<div className="info-list-entry">  					<dt>Header Name</dt> diff --git a/web/source/settings/views/admin/http-header-permissions/overview.tsx b/web/source/settings/views/admin/http-header-permissions/overview.tsx index 54b58b642..b2d8b7372 100644 --- a/web/source/settings/views/admin/http-header-permissions/overview.tsx +++ b/web/source/settings/views/admin/http-header-permissions/overview.tsx @@ -27,6 +27,7 @@ import { PermType } from "../../../lib/types/perm";  import { FetchBaseQueryError } from "@reduxjs/toolkit/query";  import { SerializedError } from "@reduxjs/toolkit";  import HeaderPermCreateForm from "./create"; +import { useCapitalize } from "../../../lib/util";  export default function HeaderPermsOverview() {  	const [ location, setLocation ] = useLocation(); @@ -41,9 +42,7 @@ export default function HeaderPermsOverview() {  	}, [params]);  	// Uppercase first letter of given permType. -	const permTypeUpper = useMemo(() => { -		return permType.charAt(0).toUpperCase() + permType.slice(1);  -	}, [permType]); +	const permTypeUpper = useCapitalize(permType);  	// Fetch desired perms, skipping  	// the ones we don't want. diff --git a/web/source/settings/views/moderation/accounts/pending/index.tsx b/web/source/settings/views/moderation/accounts/pending/index.tsx index f03c4800c..10f7d726a 100644 --- a/web/source/settings/views/moderation/accounts/pending/index.tsx +++ b/web/source/settings/views/moderation/accounts/pending/index.tsx @@ -21,7 +21,7 @@ import React, { ReactNode } from "react";  import { useSearchAccountsQuery } from "../../../../lib/query/admin";  import { PageableList } from "../../../../components/pageable-list";  import { useLocation } from "wouter"; -import Username from "../../../../components/username"; +import UsernameLozenge from "../../../../components/username-lozenge";  import { AdminAccount } from "../../../../lib/types/account";  export default function AccountsPending() { @@ -32,7 +32,7 @@ export default function AccountsPending() {  	function itemToEntry(account: AdminAccount): ReactNode {  		const acc = account.account;  		return ( -			<Username +			<UsernameLozenge  				key={acc.acct}  				account={account}  				linkTo={`/${account.id}`} diff --git a/web/source/settings/views/moderation/accounts/search/index.tsx b/web/source/settings/views/moderation/accounts/search/index.tsx index 504746adc..3b9e53ba2 100644 --- a/web/source/settings/views/moderation/accounts/search/index.tsx +++ b/web/source/settings/views/moderation/accounts/search/index.tsx @@ -26,8 +26,8 @@ import { Select, TextInput } from "../../../../components/form/inputs";  import MutationButton from "../../../../components/form/mutation-button";  import { useLocation, useSearch } from "wouter";  import { AdminAccount } from "../../../../lib/types/account"; -import Username from "../../../../components/username"; -import isValidDomain from "is-valid-domain"; +import UsernameLozenge from "../../../../components/username-lozenge"; +import { formDomainValidator } from "../../../../lib/util/formvalidators";  export function AccountSearchForm() {  	const [ location, setLocation ] = useLocation(); @@ -45,28 +45,7 @@ export function AccountSearchForm() {  		display_name: useTextInput("display_name", { defaultValue: urlQueryParams.get("display_name") ?? ""}),  		by_domain: useTextInput("by_domain", {  			defaultValue: urlQueryParams.get("by_domain") ?? "", -			validator: (v: string) => { -				if (v.length === 0) { -					return ""; -				} - -				if (v[v.length-1] === ".") { -					return "invalid domain"; -				} - -				const valid = isValidDomain(v, { -					subdomain: true, -					wildcard: false, -					allowUnicode: true, -					topLevel: false, -				}); - -				if (valid) { -					return ""; -				} - -				return "invalid domain"; -			} +			validator: formDomainValidator,  		}),  		email: useTextInput("email", { defaultValue: urlQueryParams.get("email") ?? ""}),  		ip: useTextInput("ip", { defaultValue: urlQueryParams.get("ip") ?? ""}), @@ -114,7 +93,7 @@ export function AccountSearchForm() {  	function itemToEntry(account: AdminAccount): ReactNode {  		const acc = account.account;  		return ( -			<Username +			<UsernameLozenge  				key={acc.acct}  				account={account}  				linkTo={`/${account.id}`} diff --git a/web/source/settings/views/moderation/domain-permissions/detail.tsx b/web/source/settings/views/moderation/domain-permissions/detail.tsx index 2b27b534d..0105d9615 100644 --- a/web/source/settings/views/moderation/domain-permissions/detail.tsx +++ b/web/source/settings/views/moderation/domain-permissions/detail.tsx @@ -39,37 +39,47 @@ import { NoArg } from "../../../lib/types/query";  import { Error } from "../../../components/error";  import { useBaseUrl } from "../../../lib/navigation/util";  import { PermType } from "../../../lib/types/perm"; -import isValidDomain from "is-valid-domain"; +import { useCapitalize } from "../../../lib/util"; +import { formDomainValidator } from "../../../lib/util/formvalidators";  export default function DomainPermDetail() {  	const baseUrl = useBaseUrl(); -	 -	// Parse perm type from routing params. -	let params = useParams(); -	if (params.permType !== "blocks" && params.permType !== "allows") { +	const search = useSearch(); + +	// Parse perm type from routing params, converting +	// "blocks" => "block" and "allows" => "allow". +	const params = useParams(); +	const permTypeRaw = params.permType; +	if (permTypeRaw !== "blocks" && permTypeRaw !== "allows") {  		throw "unrecognized perm type " + params.permType;  	} -	const permType = params.permType.slice(0, -1) as PermType; +	const permType = useMemo(() => { +		return permTypeRaw.slice(0, -1) as PermType; +	}, [permTypeRaw]); -	const { data: domainBlocks = {}, isLoading: isLoadingDomainBlocks } = useDomainBlocksQuery(NoArg, { skip: permType !== "block" }); -	const { data: domainAllows = {}, isLoading: isLoadingDomainAllows } = useDomainAllowsQuery(NoArg, { skip: permType !== "allow" }); - -	let isLoading; -	switch (permType) { -		case "block": -			isLoading = isLoadingDomainBlocks; -			break; -		case "allow": -			isLoading = isLoadingDomainAllows; -			break; -		default: -			throw "perm type unknown"; +	// Conditionally fetch either domain blocks or domain +	// allows depending on which perm type we're looking at. +	const { +		data: blocks = {}, +		isLoading: loadingBlocks, +		isFetching: fetchingBlocks, +	} = useDomainBlocksQuery(NoArg, { skip: permType !== "block" }); +	const { +		data: allows = {}, +		isLoading: loadingAllows, +		isFetching: fetchingAllows, +	} = useDomainAllowsQuery(NoArg, { skip: permType !== "allow" }); + +	// Wait until we're done loading. +	const loading = permType === "block" +		? loadingBlocks || fetchingBlocks +		: loadingAllows || fetchingAllows; +	if (loading) { +		return <Loading />;  	}  	// Parse domain from routing params.  	let domain = params.domain ?? "unknown"; - -	const search = useSearch();  	if (domain === "view") {  		// Retrieve domain from form field submission.  		const searchParams = new URLSearchParams(search); @@ -81,36 +91,41 @@ export default function DomainPermDetail() {  		domain = searchDomain;  	} -	// Normalize / decode domain (it may be URL-encoded). +	// Normalize / decode domain +	// (it may be URL-encoded).  	domain = decodeURIComponent(domain); -	// Check if we already have a perm of the desired type for this domain. -	const existingPerm: DomainPerm | undefined = useMemo(() => { -		if (permType == "block") { -			return domainBlocks[domain]; -		} else { -			return domainAllows[domain]; -		} -	}, [domainBlocks, domainAllows, domain, permType]); - +	// Check if we already have a perm +	// of the desired type for this domain. +	const existingPerm = permType === "block" +		? blocks[domain] +		: allows[domain]; +	 +	// Render different into content depending on +	// if we have a perm already for this domain.  	let infoContent: React.JSX.Element; - -	if (isLoading) { -		infoContent = <Loading />; -	} else if (existingPerm == undefined) { -		infoContent = <span>No stored {permType} yet, you can add one below:</span>; +	if (existingPerm === undefined) { +		infoContent = ( +			<span> +				No stored {permType} yet, you can add one below: +			</span> +		);  	} else {  		infoContent = (  			<div className="info">  				<i className="fa fa-fw fa-exclamation-triangle" aria-hidden="true"></i> -				<b>Editing domain permissions isn't implemented yet, <a href="https://github.com/superseriousbusiness/gotosocial/issues/1198" target="_blank" rel="noopener noreferrer">check here for progress</a></b> +				<b>Editing existing domain {permTypeRaw} isn't implemented yet, <a href="https://github.com/superseriousbusiness/gotosocial/issues/1198" target="_blank" rel="noopener noreferrer">check here for progress</a></b>  			</div>  		);  	}  	return (  		<div> -			<h1 className="text-cutoff"><BackButton to={`~${baseUrl}/${permType}s`}/> Domain {permType} for: <span title={domain}>{domain}</span></h1> +			<h1 className="text-cutoff"> +				<BackButton to={`~${baseUrl}/${permTypeRaw}`} /> +				{" "} +				Domain {permType} for {domain} +			</h1>  			{infoContent}  			<DomainPermForm  				defaultDomain={domain} @@ -143,28 +158,7 @@ function DomainPermForm({ defaultDomain, perm, permType }: DomainPermFormProps)  		domain: useTextInput("domain", {  			source: perm,  			defaultValue: defaultDomain, -			validator: (v: string) => { -				if (v.length === 0) { -					return ""; -				} - -				if (v[v.length-1] === ".") { -					return "invalid domain"; -				} - -				const valid = isValidDomain(v, { -					subdomain: true, -					wildcard: false, -					allowUnicode: true, -					topLevel: false, -				}); - -				if (valid) { -					return ""; -				} - -				return "invalid domain"; -			} +			validator: formDomainValidator,  		}),  		obfuscate: useBoolInput("obfuscate", { source: perm }),  		commentPrivate: useTextInput("private_comment", { source: perm }), @@ -209,9 +203,7 @@ function DomainPermForm({ defaultDomain, perm, permType }: DomainPermFormProps)  	const [submitForm, submitFormResult] = useFormSubmit(form, [addTrigger, addResult], { changedOnly: false });  	// Uppercase first letter of given permType. -	const permTypeUpper = useMemo(() => { -		return permType.charAt(0).toUpperCase() + permType.slice(1);  -	}, [permType]); +	const permTypeUpper = useCapitalize(permType);  	const [location, setLocation] = useLocation(); diff --git a/web/source/settings/views/moderation/domain-permissions/drafts/common.tsx b/web/source/settings/views/moderation/domain-permissions/drafts/common.tsx new file mode 100644 index 000000000..af919dc57 --- /dev/null +++ b/web/source/settings/views/moderation/domain-permissions/drafts/common.tsx @@ -0,0 +1,43 @@ +/* +	GoToSocial +	Copyright (C) GoToSocial Authors admin@gotosocial.org +	SPDX-License-Identifier: AGPL-3.0-or-later + +	This program is free software: you can redistribute it and/or modify +	it under the terms of the GNU Affero General Public License as published by +	the Free Software Foundation, either version 3 of the License, or +	(at your option) any later version. + +	This program is distributed in the hope that it will be useful, +	but WITHOUT ANY WARRANTY; without even the implied warranty of +	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +	GNU Affero General Public License for more details. + +	You should have received a copy of the GNU Affero General Public License +	along with this program.  If not, see <http://www.gnu.org/licenses/>. +*/ + +import React from "react"; + +export function DomainPermissionDraftHelpText() { +	return ( +		<> +			Domain permission drafts are domain block or domain allow entries that are not yet in force. +			<br/> +			You can choose to accept or remove a draft. +		</> +	); +} + +export function DomainPermissionDraftDocsLink() { +	return ( +		<a +			href="https://docs.gotosocial.org/en/latest/admin/settings/#domain-permission-drafts" +			target="_blank" +			className="docslink" +			rel="noreferrer" +		> +			Learn more about domain permission drafts (opens in a new tab) +		</a> +	); +} diff --git a/web/source/settings/views/moderation/domain-permissions/drafts/detail.tsx b/web/source/settings/views/moderation/domain-permissions/drafts/detail.tsx new file mode 100644 index 000000000..a5ba325f0 --- /dev/null +++ b/web/source/settings/views/moderation/domain-permissions/drafts/detail.tsx @@ -0,0 +1,210 @@ +/* +	GoToSocial +	Copyright (C) GoToSocial Authors admin@gotosocial.org +	SPDX-License-Identifier: AGPL-3.0-or-later + +	This program is free software: you can redistribute it and/or modify +	it under the terms of the GNU Affero General Public License as published by +	the Free Software Foundation, either version 3 of the License, or +	(at your option) any later version. + +	This program is distributed in the hope that it will be useful, +	but WITHOUT ANY WARRANTY; without even the implied warranty of +	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +	GNU Affero General Public License for more details. + +	You should have received a copy of the GNU Affero General Public License +	along with this program.  If not, see <http://www.gnu.org/licenses/>. +*/ + +import React from "react"; +import { useLocation, useParams } from "wouter"; +import Loading from "../../../../components/loading"; +import { useBaseUrl } from "../../../../lib/navigation/util"; +import BackButton from "../../../../components/back-button"; +import { +	useAcceptDomainPermissionDraftMutation, +	useGetDomainPermissionDraftQuery, +	useRemoveDomainPermissionDraftMutation +} from "../../../../lib/query/admin/domain-permissions/drafts"; +import { Error as ErrorC } from "../../../../components/error"; +import UsernameLozenge from "../../../../components/username-lozenge"; +import MutationButton from "../../../../components/form/mutation-button"; +import { useBoolInput, useTextInput } from "../../../../lib/form"; +import { Checkbox, Select } from "../../../../components/form/inputs"; +import { PermType } from "../../../../lib/types/perm"; + +export default function DomainPermissionDraftDetail() { +	const baseUrl = useBaseUrl(); +	const backLocation: string = history.state?.backLocation ?? `~${baseUrl}`; +	const params = useParams(); + +	let id = params.permDraftId as string | undefined; +	if (!id) { +		throw "no perm ID"; +	} + +	const { +		data: permDraft, +		isLoading, +		isFetching, +		isError, +		error, +	} = useGetDomainPermissionDraftQuery(id); + +	if (isLoading || isFetching) { +		return <Loading />; +	} else if (isError) { +		return <ErrorC error={error} />; +	} else if (permDraft === undefined) { +		return <ErrorC error={new Error("permission draft was undefined")} />; +	} + +	const created = permDraft.created_at ? new Date(permDraft.created_at).toDateString(): "unknown"; +	const domain = permDraft.domain; +	const permType = permDraft.permission_type; +	if (!permType) { +		return <ErrorC error={new Error("permission_type was undefined")} />; +	} +	const publicComment = permDraft.public_comment ?? "[none]"; +	const privateComment = permDraft.private_comment ?? "[none]"; +	const subscriptionID = permDraft.subscription_id ?? "[none]"; + +	return ( +		<div className="domain-permission-draft-details"> +			<h1><BackButton to={backLocation} /> Domain Permission Draft Detail</h1> +			<dl className="info-list"> +				<div className="info-list-entry"> +					<dt>Created</dt> +					<dd><time dateTime={permDraft.created_at}>{created}</time></dd> +				</div> +				<div className="info-list-entry"> +					<dt>Created By</dt> +					<dd> +						<UsernameLozenge +							account={permDraft.created_by} +							linkTo={`~/settings/moderation/accounts/${permDraft.created_by}`} +							backLocation={`~${location}`} +						/> +					</dd> +				</div> +				<div className="info-list-entry"> +					<dt>Domain</dt> +					<dd>{domain}</dd> +				</div> +				<div className="info-list-entry"> +					<dt>Permission type</dt> +					<dd className={`permission-type ${permType}`}> +						<i +							aria-hidden={true} +							className={`fa fa-${permType === "allow" ? "check" : "close"}`} +						></i> +						{permType} +					</dd> +				</div> +				<div className="info-list-entry"> +					<dt>Private comment</dt> +					<dd>{privateComment}</dd> +				</div> +				<div className="info-list-entry"> +					<dt>Public comment</dt> +					<dd>{publicComment}</dd> +				</div> +				<div className="info-list-entry"> +					<dt>Subscription ID</dt> +					<dd>{subscriptionID}</dd> +				</div> +			</dl> +			<HandleDraft +				id={id} +				permType={permType} +				backLocation={backLocation} +			/>  +		</div> +	); +} + +function HandleDraft({ id, permType, backLocation }: { id: string, permType: PermType, backLocation: string }) { +	const [ accept, acceptResult ] = useAcceptDomainPermissionDraftMutation(); +	const [ remove, removeResult ] = useRemoveDomainPermissionDraftMutation(); +	const [_location, setLocation] = useLocation(); +	const form = { +		acceptOrRemove: useTextInput("accept_or_remove", { defaultValue: "accept" }), +		overwrite: useBoolInput("overwrite"), +		exclude_target: useBoolInput("exclude_target"), +	}; + +	const onClick = (e) => { +		e.preventDefault(); +		if (form.acceptOrRemove.value === "accept") { +			const overwrite = form.overwrite.value; +			accept({id, overwrite, permType}).then(res => { +				if ("data" in res) { +					setLocation(backLocation); +				} +			}); +		} else { +			const exclude_target = form.exclude_target.value; +			remove({id, exclude_target}).then(res => { +				if ("data" in res) { +					setLocation(backLocation); +				} +			});	 +		} +	}; + +	return ( +		<form> +			<Select +				field={form.acceptOrRemove} +				label="Accept or remove draft" +				options={ +					<> +						<option value="accept">Accept</option> +						<option value="remove">Remove</option> +					</> +				} +			></Select> +			 +			{ form.acceptOrRemove.value === "accept" && +				<> +					<Checkbox +						field={form.overwrite} +						label={`Overwrite any existing ${permType} for this domain`} +					/> +				</> +			} + +			{ form.acceptOrRemove.value === "remove" && +				<> +					<Checkbox +						field={form.exclude_target} +						label={`Add a domain permission exclude for this domain`} +					/> +				</> +			} + +			<MutationButton +				label={ +					form.acceptOrRemove.value === "accept" +						? `Accept ${permType}` +						: "Remove draft" +				} +				type="button" +				className={ +					form.acceptOrRemove.value === "accept" +						? "button" +						: "button danger" +				} +				onClick={onClick} +				disabled={false} +				showError={true} +				result={ +					form.acceptOrRemove.value === "accept" +						? acceptResult +						: removeResult +				} +			/> +		</form> +	); +} diff --git a/web/source/settings/views/moderation/domain-permissions/drafts/index.tsx b/web/source/settings/views/moderation/domain-permissions/drafts/index.tsx new file mode 100644 index 000000000..19dbe0d88 --- /dev/null +++ b/web/source/settings/views/moderation/domain-permissions/drafts/index.tsx @@ -0,0 +1,293 @@ +/* +	GoToSocial +	Copyright (C) GoToSocial Authors admin@gotosocial.org +	SPDX-License-Identifier: AGPL-3.0-or-later + +	This program is free software: you can redistribute it and/or modify +	it under the terms of the GNU Affero General Public License as published by +	the Free Software Foundation, either version 3 of the License, or +	(at your option) any later version. + +	This program is distributed in the hope that it will be useful, +	but WITHOUT ANY WARRANTY; without even the implied warranty of +	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +	GNU Affero General Public License for more details. + +	You should have received a copy of the GNU Affero General Public License +	along with this program.  If not, see <http://www.gnu.org/licenses/>. +*/ + +import React, { ReactNode, useEffect, useMemo } from "react"; + +import { useTextInput } from "../../../../lib/form"; +import { PageableList } from "../../../../components/pageable-list"; +import MutationButton from "../../../../components/form/mutation-button"; +import { useLocation, useSearch } from "wouter"; +import { useAcceptDomainPermissionDraftMutation, useLazySearchDomainPermissionDraftsQuery, useRemoveDomainPermissionDraftMutation } from "../../../../lib/query/admin/domain-permissions/drafts"; +import { DomainPerm } from "../../../../lib/types/domain-permission"; +import { Error as ErrorC } from "../../../../components/error"; +import { Select, TextInput } from "../../../../components/form/inputs"; +import { formDomainValidator } from "../../../../lib/util/formvalidators"; +import { useCapitalize } from "../../../../lib/util"; +import { DomainPermissionDraftDocsLink, DomainPermissionDraftHelpText } from "./common"; + +export default function DomainPermissionDraftsSearch() { +	return ( +		<div className="domain-permission-drafts-view"> +			<div className="form-section-docs"> +				<h1>Domain Permission Drafts</h1> +				<p> +					You can use the form below to search through domain permission drafts. +					<br/> +					<DomainPermissionDraftHelpText /> +				</p> +				<DomainPermissionDraftDocsLink /> +			</div> +			<DomainPermissionDraftsSearchForm /> +		</div> +	); +} + +function DomainPermissionDraftsSearchForm() { +	const [ location, setLocation ] = useLocation(); +	const search = useSearch(); +	const urlQueryParams = useMemo(() => new URLSearchParams(search), [search]); +	const hasParams = urlQueryParams.size != 0; +	const [ searchDrafts, searchRes ] = useLazySearchDomainPermissionDraftsQuery(); + +	const form = { +		subscription_id: useTextInput("subscription_id", { defaultValue: urlQueryParams.get("subscription_id") ?? "" }), +		domain: useTextInput("domain", { +			defaultValue: urlQueryParams.get("domain") ?? "", +			validator: formDomainValidator, +		}), +		permission_type: useTextInput("permission_type", { defaultValue: urlQueryParams.get("permission_type") ?? "" }), +		limit: useTextInput("limit", { defaultValue: urlQueryParams.get("limit") ?? "20" }) +	}; + +	// On mount, if urlQueryParams were provided, +	// trigger the search. For example, if page +	// was accessed at /search?origin=local&limit=20, +	// then run a search with origin=local and +	// limit=20 and immediately render the results. +	// +	// If no urlQueryParams set, trigger default +	// search (first page, no filtering). +	useEffect(() => { +		if (hasParams) { +			searchDrafts(Object.fromEntries(urlQueryParams)); +		} else { +			setLocation(location + "?limit=20"); +		} +	}, [ +		urlQueryParams, +		hasParams, +		searchDrafts, +		location, +		setLocation, +	]); + +	// Rather than triggering the search directly, +	// the "submit" button changes the location +	// based on form field params, and lets the +	// useEffect hook above actually do the search. +	function submitQuery(e) { +		e.preventDefault(); +		 +		// Parse query parameters. +		const entries = Object.entries(form).map(([k, v]) => { +			// Take only defined form fields. +			if (v.value === undefined || v.value.length === 0 || v.value === "any") { +				return null; +			} +			return [[k, v.value]]; +		}).flatMap(kv => { +			// Remove any nulls. +			return kv || []; +		}); + +		const searchParams = new URLSearchParams(entries); +		setLocation(location + "?" + searchParams.toString()); +	} + +	// Location to return to when user clicks "back" on the detail view. +	const backLocation = location + (hasParams ? `?${urlQueryParams}` : ""); +	 +	// Function to map an item to a list entry. +	function itemToEntry(draft: DomainPerm): ReactNode { +		return ( +			<DraftListEntry +				key={draft.id}	 +				permDraft={draft} +				linkTo={`/drafts/${draft.id}`} +				backLocation={backLocation} +			/> +		); +	} + +	return ( +		<> +			<form +				onSubmit={submitQuery} +				// Prevent password managers +				// trying to fill in fields. +				autoComplete="off" +			> +				<Select +					field={form.permission_type} +					label="Permission type" +					options={ +						<> +							<option value="">Any</option> +							<option value="block">Block</option> +							<option value="allow">Allow</option> +						</> +					} +				></Select> +				<TextInput +					field={form.domain} +					label={`Domain (without "https://" prefix)`} +					placeholder="example.org" +					autoCapitalize="none" +					spellCheck="false" +				/> +				<Select +					field={form.limit} +					label="Items per page" +					options={ +						<> +							<option value="20">20</option> +							<option value="50">50</option> +							<option value="100">100</option> +						</> +					} +				></Select> +				<MutationButton +					disabled={false} +					label={"Search"} +					result={searchRes} +				/> +			</form> +			<PageableList +				isLoading={searchRes.isLoading} +				isFetching={searchRes.isFetching} +				isSuccess={searchRes.isSuccess} +				items={searchRes.data?.drafts} +				itemToEntry={itemToEntry} +				isError={searchRes.isError} +				error={searchRes.error} +				emptyMessage={<b>No drafts found that match your query.</b>} +				prevNextLinks={searchRes.data?.links} +			/> +		</> +	); +} + +interface DraftEntryProps { +	permDraft: DomainPerm; +	linkTo: string; +	backLocation: string; +} + +function DraftListEntry({ permDraft, linkTo, backLocation }: DraftEntryProps) { +	const [ _location, setLocation ] = useLocation(); +	const [ accept, acceptResult ] = useAcceptDomainPermissionDraftMutation(); +	const [ remove, removeResult ] = useRemoveDomainPermissionDraftMutation(); + +	const domain = permDraft.domain; +	const permType = permDraft.permission_type; +	const permTypeUpper = useCapitalize(permType); +	if (!permType) { +		return <ErrorC error={new Error("permission_type was undefined")} />; +	} + +	const publicComment = permDraft.public_comment ?? "[none]"; +	const privateComment = permDraft.private_comment ?? "[none]"; +	const subscriptionID = permDraft.subscription_id ?? "[none]"; +	const id = permDraft.id; +	if (!id) { +		return <ErrorC error={new Error("id was undefined")} />; +	} + +	const title = `${permTypeUpper} ${domain}`; + +	return ( +		<span +			className={`pseudolink domain-permission-draft entry ${permType}`} +			aria-label={title} +			title={title} +			onClick={() => { +				// When clicking on a draft, direct +				// to the detail view for that draft. +				setLocation(linkTo, { +					// Store the back location in history so +					// the detail view can use it to return to +					// this page (including query parameters). +					state: { backLocation: backLocation } +				}); +			}} +			role="link" +			tabIndex={0} +		> +			<h3>{title}</h3> +			<dl className="info-list"> +				<div className="info-list-entry"> +					<dt>Domain:</dt> +					<dd className="text-cutoff">{domain}</dd> +				</div> +				<div className="info-list-entry"> +					<dt>Permission type:</dt> +					<dd className={`permission-type ${permType}`}> +						<i +							aria-hidden={true} +							className={`fa fa-${permType === "allow" ? "check" : "close"}`} +						></i> +						{permType} +					</dd> +				</div> +				<div className="info-list-entry"> +					<dt>Private comment:</dt> +					<dd className="text-cutoff">{privateComment}</dd> +				</div> +				<div className="info-list-entry"> +					<dt>Public comment:</dt> +					<dd>{publicComment}</dd> +				</div> +				<div className="info-list-entry"> +					<dt>Subscription:</dt> +					<dd className="text-cutoff">{subscriptionID}</dd> +				</div> +			</dl> +			<div className="action-buttons"> +				<MutationButton +					label={`Accept ${permType}`} +					title={`Accept ${permType}`} +					type="button" +					className="button" +					onClick={(e) => { +						e.preventDefault(); +						e.stopPropagation(); +						accept({ id, permType }); +					}} +					disabled={false} +					showError={true} +					result={acceptResult} +				/> +				<MutationButton +					label={`Remove draft`} +					title={`Remove draft`} +					type="button" +					className="button danger" +					onClick={(e) => { +						e.preventDefault(); +						e.stopPropagation(); +						remove({ id }); +					}} +					disabled={false} +					showError={true} +					result={removeResult} +				/> +			</div> +		</span> +	); +} diff --git a/web/source/settings/views/moderation/domain-permissions/drafts/new.tsx b/web/source/settings/views/moderation/domain-permissions/drafts/new.tsx new file mode 100644 index 000000000..c78f8192a --- /dev/null +++ b/web/source/settings/views/moderation/domain-permissions/drafts/new.tsx @@ -0,0 +1,119 @@ +/* +	GoToSocial +	Copyright (C) GoToSocial Authors admin@gotosocial.org +	SPDX-License-Identifier: AGPL-3.0-or-later + +	This program is free software: you can redistribute it and/or modify +	it under the terms of the GNU Affero General Public License as published by +	the Free Software Foundation, either version 3 of the License, or +	(at your option) any later version. + +	This program is distributed in the hope that it will be useful, +	but WITHOUT ANY WARRANTY; without even the implied warranty of +	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +	GNU Affero General Public License for more details. + +	You should have received a copy of the GNU Affero General Public License +	along with this program.  If not, see <http://www.gnu.org/licenses/>. +*/ + +import React from "react"; +import useFormSubmit from "../../../../lib/form/submit"; +import { useCreateDomainPermissionDraftMutation } from "../../../../lib/query/admin/domain-permissions/drafts"; +import { useBoolInput, useRadioInput, useTextInput } from "../../../../lib/form"; +import { formDomainValidator } from "../../../../lib/util/formvalidators"; +import MutationButton from "../../../../components/form/mutation-button"; +import { Checkbox, RadioGroup, TextArea, TextInput } from "../../../../components/form/inputs"; +import { useLocation } from "wouter"; +import { DomainPermissionDraftDocsLink, DomainPermissionDraftHelpText } from "./common"; + +export default function DomainPermissionDraftNew() { +	const [ _location, setLocation ] = useLocation(); +	 +	const form = { +		domain: useTextInput("domain", { +			validator: formDomainValidator, +		}), +		permission_type: useRadioInput("permission_type", {  +			options: { +				block: "Block domain", +				allow: "Allow domain", +			} +		}), +		obfuscate: useBoolInput("obfuscate"), +		public_comment: useTextInput("public_comment"), +		private_comment: useTextInput("private_comment"), +	}; +		 +	const [formSubmit, result] = useFormSubmit( +		form, +		useCreateDomainPermissionDraftMutation(), +		{ +			changedOnly: false, +			onFinish: (res) => { +				if (res.data) { +					// Creation successful, +					// redirect to drafts overview. +					setLocation(`/drafts/search`); +				} +			}, +		}); + +	return ( +		<form +			onSubmit={formSubmit} +			// Prevent password managers +			// trying to fill in fields. +			autoComplete="off" +		> +			<div className="form-section-docs"> +				<h2>New Domain Permission Draft</h2> +				<p><DomainPermissionDraftHelpText /></p> +				<DomainPermissionDraftDocsLink /> +			</div> + +			<RadioGroup +				field={form.permission_type} +			/> + +			<TextInput +				field={form.domain} +				label={`Domain (without "https://" prefix)`} +				placeholder="example.org" +				autoCapitalize="none" +				spellCheck="false" +			/> + +			<TextArea +				field={form.private_comment} +				label={"Private comment"} +				placeholder="This domain is like unto a clown car full of clowns, I suggest we block it forthwith." +				autoCapitalize="sentences" +				rows={3} +			/> + +			<TextArea +				field={form.public_comment} +				label={"Public comment"} +				placeholder="Bad posters" +				autoCapitalize="sentences" +				rows={3} +			/> + +			<Checkbox +				field={form.obfuscate} +				label="Obfuscate domain in public lists" +			/> + +			<MutationButton +				label="Save" +				result={result} +				disabled={ +					!form.domain.value || +					!form.domain.valid || +					!form.permission_type.value +				} +			/> +		</form> +	); +} diff --git a/web/source/settings/views/moderation/domain-permissions/excludes/common.tsx b/web/source/settings/views/moderation/domain-permissions/excludes/common.tsx new file mode 100644 index 000000000..f88f0af68 --- /dev/null +++ b/web/source/settings/views/moderation/domain-permissions/excludes/common.tsx @@ -0,0 +1,54 @@ +/* +	GoToSocial +	Copyright (C) GoToSocial Authors admin@gotosocial.org +	SPDX-License-Identifier: AGPL-3.0-or-later + +	This program is free software: you can redistribute it and/or modify +	it under the terms of the GNU Affero General Public License as published by +	the Free Software Foundation, either version 3 of the License, or +	(at your option) any later version. + +	This program is distributed in the hope that it will be useful, +	but WITHOUT ANY WARRANTY; without even the implied warranty of +	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +	GNU Affero General Public License for more details. + +	You should have received a copy of the GNU Affero General Public License +	along with this program.  If not, see <http://www.gnu.org/licenses/>. +*/ + +import React from "react"; + +export function DomainPermissionExcludeHelpText() { +	return ( +		<> +			Domain permission excludes prevent permissions for a domain (and all +			subdomains) from being auomatically managed by domain permission subscriptions. +			<br/> +			For example, if you create an exclude entry for <code>example.org</code>, then +			a blocklist or allowlist subscription will <em>exclude</em> entries for <code>example.org</code> +			and any of its subdomains (<code>sub.example.org</code>, <code>another.sub.example.org</code> etc.) +			when creating domain permission drafts and domain blocks/allows. +			<br/> +			This functionality allows you to manually manage permissions for excluded domains, +			in cases where you know you definitely do or don't want to federate with a given domain, +			no matter what entries are contained in a domain permission subscription. +			<br/> +			Note that by itself, creation of an exclude entry for a given domain does not affect +			federation with that domain at all, it is only useful in combination with permission subscriptions. +		</> +	); +} + +export function DomainPermissionExcludeDocsLink() { +	return ( +		<a +			href="https://docs.gotosocial.org/en/latest/admin/settings/#domain-permission-excludes" +			target="_blank" +			className="docslink" +			rel="noreferrer" +		> +			Learn more about domain permission excludes (opens in a new tab) +		</a> +	); +} diff --git a/web/source/settings/views/moderation/domain-permissions/excludes/detail.tsx b/web/source/settings/views/moderation/domain-permissions/excludes/detail.tsx new file mode 100644 index 000000000..4e14ec3ad --- /dev/null +++ b/web/source/settings/views/moderation/domain-permissions/excludes/detail.tsx @@ -0,0 +1,119 @@ +/* +	GoToSocial +	Copyright (C) GoToSocial Authors admin@gotosocial.org +	SPDX-License-Identifier: AGPL-3.0-or-later + +	This program is free software: you can redistribute it and/or modify +	it under the terms of the GNU Affero General Public License as published by +	the Free Software Foundation, either version 3 of the License, or +	(at your option) any later version. + +	This program is distributed in the hope that it will be useful, +	but WITHOUT ANY WARRANTY; without even the implied warranty of +	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +	GNU Affero General Public License for more details. + +	You should have received a copy of the GNU Affero General Public License +	along with this program.  If not, see <http://www.gnu.org/licenses/>. +*/ + +import React from "react"; +import { useLocation, useParams } from "wouter"; +import Loading from "../../../../components/loading"; +import { useBaseUrl } from "../../../../lib/navigation/util"; +import BackButton from "../../../../components/back-button"; +import { Error as ErrorC } from "../../../../components/error"; +import UsernameLozenge from "../../../../components/username-lozenge"; +import { useDeleteDomainPermissionExcludeMutation, useGetDomainPermissionExcludeQuery } from "../../../../lib/query/admin/domain-permissions/excludes"; +import MutationButton from "../../../../components/form/mutation-button"; + +export default function DomainPermissionExcludeDetail() { +	const baseUrl = useBaseUrl(); +	const backLocation: string = history.state?.backLocation ?? `~${baseUrl}`; + +	const params = useParams(); +	let id = params.excludeId as string | undefined; +	if (!id) { +		throw "no perm ID"; +	} + +	const { +		data: permExclude, +		isLoading, +		isFetching, +		isError, +		error, +	} = useGetDomainPermissionExcludeQuery(id); + +	if (isLoading || isFetching) { +		return <Loading />; +	} else if (isError) { +		return <ErrorC error={error} />; +	} else if (permExclude === undefined) { +		return <ErrorC error={new Error("permission exclude was undefined")} />; +	} + +	const created = permExclude.created_at ? new Date(permExclude.created_at).toDateString(): "unknown"; +	const domain = permExclude.domain; +	const privateComment = permExclude.private_comment ?? "[none]"; + +	return ( +		<div className="domain-permission-exclude-details"> +			<h1><BackButton to={backLocation} /> Domain Permission Exclude Detail</h1> +			<dl className="info-list"> +				<div className="info-list-entry"> +					<dt>Created</dt> +					<dd><time dateTime={permExclude.created_at}>{created}</time></dd> +				</div> +				<div className="info-list-entry"> +					<dt>Created By</dt> +					<dd> +						<UsernameLozenge +							account={permExclude.created_by} +							linkTo={`~/settings/moderation/accounts/${permExclude.created_by}`} +							backLocation={`~${location}`} +						/> +					</dd> +				</div> +				<div className="info-list-entry"> +					<dt>Domain</dt> +					<dd>{domain}</dd> +				</div> +				<div className="info-list-entry"> +					<dt>Private comment</dt> +					<dd>{privateComment}</dd> +				</div> +			</dl> +			<HandleExclude +				id={id} +				backLocation={backLocation} +			/> +		</div> +	); +} + +function HandleExclude({ id, backLocation}: {id: string, backLocation: string}) { +	const [_location, setLocation] = useLocation(); +	const [ deleteExclude, deleteResult ] = useDeleteDomainPermissionExcludeMutation(); +	 +	return ( +		<MutationButton +			label={`Delete exclude`} +			title={`Delete exclude`} +			type="button" +			className="button danger" +			onClick={(e) => { +				e.preventDefault(); +				e.stopPropagation(); +				deleteExclude(id).then(res => { +					if ("data" in res) { +						setLocation(backLocation); +					} +				}); +			}} +			disabled={false} +			showError={true} +			result={deleteResult} +		/> +	); +} diff --git a/web/source/settings/views/moderation/domain-permissions/excludes/index.tsx b/web/source/settings/views/moderation/domain-permissions/excludes/index.tsx new file mode 100644 index 000000000..915d6f5cc --- /dev/null +++ b/web/source/settings/views/moderation/domain-permissions/excludes/index.tsx @@ -0,0 +1,235 @@ +/* +	GoToSocial +	Copyright (C) GoToSocial Authors admin@gotosocial.org +	SPDX-License-Identifier: AGPL-3.0-or-later + +	This program is free software: you can redistribute it and/or modify +	it under the terms of the GNU Affero General Public License as published by +	the Free Software Foundation, either version 3 of the License, or +	(at your option) any later version. + +	This program is distributed in the hope that it will be useful, +	but WITHOUT ANY WARRANTY; without even the implied warranty of +	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +	GNU Affero General Public License for more details. + +	You should have received a copy of the GNU Affero General Public License +	along with this program.  If not, see <http://www.gnu.org/licenses/>. +*/ + +import React, { ReactNode, useEffect, useMemo } from "react"; + +import { useTextInput } from "../../../../lib/form"; +import { PageableList } from "../../../../components/pageable-list"; +import MutationButton from "../../../../components/form/mutation-button"; +import { useLocation, useSearch } from "wouter"; +import { useDeleteDomainPermissionExcludeMutation, useLazySearchDomainPermissionExcludesQuery } from "../../../../lib/query/admin/domain-permissions/excludes"; +import { DomainPerm } from "../../../../lib/types/domain-permission"; +import { Error as ErrorC } from "../../../../components/error"; +import { Select, TextInput } from "../../../../components/form/inputs"; +import { formDomainValidator } from "../../../../lib/util/formvalidators"; +import { DomainPermissionExcludeDocsLink, DomainPermissionExcludeHelpText } from "./common"; + +export default function DomainPermissionExcludesSearch() { +	return ( +		<div className="domain-permission-excludes-view"> +			<div className="form-section-docs"> +				<h1>Domain Permission Excludes</h1> +				<p> +					You can use the form below to search through domain permission excludes. +					<br/> +					<DomainPermissionExcludeHelpText /> +				</p> +				<DomainPermissionExcludeDocsLink /> +			</div> +			<DomainPermissionExcludesSearchForm /> +		</div> +	); +} + +function DomainPermissionExcludesSearchForm() { +	const [ location, setLocation ] = useLocation(); +	const search = useSearch(); +	const urlQueryParams = useMemo(() => new URLSearchParams(search), [search]); +	const hasParams = urlQueryParams.size != 0; +	const [ searchExcludes, searchRes ] = useLazySearchDomainPermissionExcludesQuery(); + +	const form = { +		domain: useTextInput("domain", { +			defaultValue: urlQueryParams.get("domain") ?? "", +			validator: formDomainValidator, +		}), +		limit: useTextInput("limit", { defaultValue: urlQueryParams.get("limit") ?? "20" }) +	}; + +	// On mount, if urlQueryParams were provided, +	// trigger the search. For example, if page +	// was accessed at /search?origin=local&limit=20, +	// then run a search with origin=local and +	// limit=20 and immediately render the results. +	// +	// If no urlQueryParams set, trigger default +	// search (first page, no filtering). +	useEffect(() => { +		if (hasParams) { +			searchExcludes(Object.fromEntries(urlQueryParams)); +		} else { +			setLocation(location + "?limit=20"); +		} +	}, [ +		urlQueryParams, +		hasParams, +		searchExcludes, +		location, +		setLocation, +	]); + +	// Rather than triggering the search directly, +	// the "submit" button changes the location +	// based on form field params, and lets the +	// useEffect hook above actually do the search. +	function submitQuery(e) { +		e.preventDefault(); +		 +		// Parse query parameters. +		const entries = Object.entries(form).map(([k, v]) => { +			// Take only defined form fields. +			if (v.value === undefined || v.value.length === 0 || v.value === "any") { +				return null; +			} +			return [[k, v.value]]; +		}).flatMap(kv => { +			// Remove any nulls. +			return kv || []; +		}); + +		const searchParams = new URLSearchParams(entries); +		setLocation(location + "?" + searchParams.toString()); +	} + +	// Location to return to when user clicks "back" on the detail view. +	const backLocation = location + (hasParams ? `?${urlQueryParams}` : ""); +	 +	// Function to map an item to a list entry. +	function itemToEntry(exclude: DomainPerm): ReactNode { +		return ( +			<ExcludeListEntry +				key={exclude.id}	 +				permExclude={exclude} +				linkTo={`/excludes/${exclude.id}`} +				backLocation={backLocation} +			/> +		); +	} + +	return ( +		<> +			<form +				onSubmit={submitQuery} +				// Prevent password managers +				// trying to fill in fields. +				autoComplete="off" +			> +				<TextInput +					field={form.domain} +					label={`Domain (without "https://" prefix)`} +					placeholder="example.org" +					autoCapitalize="none" +					spellCheck="false" +				/> +				<Select +					field={form.limit} +					label="Items per page" +					options={ +						<> +							<option value="20">20</option> +							<option value="50">50</option> +							<option value="100">100</option> +						</> +					} +				></Select> +				<MutationButton +					disabled={false} +					label={"Search"} +					result={searchRes} +				/> +			</form> +			<PageableList +				isLoading={searchRes.isLoading} +				isFetching={searchRes.isFetching} +				isSuccess={searchRes.isSuccess} +				items={searchRes.data?.excludes} +				itemToEntry={itemToEntry} +				isError={searchRes.isError} +				error={searchRes.error} +				emptyMessage={<b>No excludes found that match your query.</b>} +				prevNextLinks={searchRes.data?.links} +			/> +		</> +	); +} + +interface ExcludeEntryProps { +	permExclude: DomainPerm; +	linkTo: string; +	backLocation: string; +} + +function ExcludeListEntry({ permExclude, linkTo, backLocation }: ExcludeEntryProps) { +	const [ _location, setLocation ] = useLocation(); +	const [ deleteExclude, deleteResult ] = useDeleteDomainPermissionExcludeMutation(); + +	const domain = permExclude.domain; +	const privateComment = permExclude.private_comment ?? "[none]"; +	const id = permExclude.id; +	if (!id) { +		return <ErrorC error={new Error("id was undefined")} />; +	} + +	return ( +		<span +			className={`pseudolink domain-permission-exclude entry`} +			aria-label={`Exclude ${domain}`} +			title={`Exclude ${domain}`} +			onClick={() => { +				// When clicking on a exclude, direct +				// to the detail view for that exclude. +				setLocation(linkTo, { +					// Store the back location in history so +					// the detail view can use it to return to +					// this page (including query parameters). +					state: { backLocation: backLocation } +				}); +			}} +			role="link" +			tabIndex={0} +		> +			<dl className="info-list"> +				<div className="info-list-entry"> +					<dt>Domain:</dt> +					<dd className="text-cutoff">{domain}</dd> +				</div> +				<div className="info-list-entry"> +					<dt>Private comment:</dt> +					<dd className="text-cutoff">{privateComment}</dd> +				</div> +			</dl> +			<div className="action-buttons"> +				<MutationButton +					label={`Delete exclude`} +					title={`Delete exclude`} +					type="button" +					className="button danger" +					onClick={(e) => { +						e.preventDefault(); +						e.stopPropagation(); +						deleteExclude(id); +					}} +					disabled={false} +					showError={true} +					result={deleteResult} +				/> +			</div> +		</span> +	); +} diff --git a/web/source/settings/views/moderation/domain-permissions/excludes/new.tsx b/web/source/settings/views/moderation/domain-permissions/excludes/new.tsx new file mode 100644 index 000000000..ad33070f8 --- /dev/null +++ b/web/source/settings/views/moderation/domain-permissions/excludes/new.tsx @@ -0,0 +1,90 @@ +/* +	GoToSocial +	Copyright (C) GoToSocial Authors admin@gotosocial.org +	SPDX-License-Identifier: AGPL-3.0-or-later + +	This program is free software: you can redistribute it and/or modify +	it under the terms of the GNU Affero General Public License as published by +	the Free Software Foundation, either version 3 of the License, or +	(at your option) any later version. + +	This program is distributed in the hope that it will be useful, +	but WITHOUT ANY WARRANTY; without even the implied warranty of +	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +	GNU Affero General Public License for more details. + +	You should have received a copy of the GNU Affero General Public License +	along with this program.  If not, see <http://www.gnu.org/licenses/>. +*/ + +import React from "react"; +import useFormSubmit from "../../../../lib/form/submit"; +import { useCreateDomainPermissionExcludeMutation } from "../../../../lib/query/admin/domain-permissions/excludes"; +import { useTextInput } from "../../../../lib/form"; +import { formDomainValidator } from "../../../../lib/util/formvalidators"; +import MutationButton from "../../../../components/form/mutation-button"; +import { TextArea, TextInput } from "../../../../components/form/inputs"; +import { useLocation } from "wouter"; +import { DomainPermissionExcludeDocsLink, DomainPermissionExcludeHelpText } from "./common"; + +export default function DomainPermissionExcludeNew() { +	const [ _location, setLocation ] = useLocation(); +	 +	const form = { +		domain: useTextInput("domain", { +			validator: formDomainValidator, +		}), +		private_comment: useTextInput("private_comment"), +	}; +		 +	const [formSubmit, result] = useFormSubmit( +		form, +		useCreateDomainPermissionExcludeMutation(), +		{ +			changedOnly: false, +			onFinish: (res) => { +				if (res.data) { +					// Creation successful, +					// redirect to excludes overview. +					setLocation(`/excludes/search`); +				} +			}, +		}); + +	return ( +		<form +			onSubmit={formSubmit} +			// Prevent password managers +			// trying to fill in fields. +			autoComplete="off" +		> +			<div className="form-section-docs"> +				<h2>New Domain Permission Exclude</h2> +				<p><DomainPermissionExcludeHelpText /></p> +				<DomainPermissionExcludeDocsLink /> +			</div> + +			<TextInput +				field={form.domain} +				label={`Domain (without "https://" prefix)`} +				placeholder="example.org" +				autoCapitalize="none" +				spellCheck="false" +			/> + +			<TextArea +				field={form.private_comment} +				label={"Private comment"} +				placeholder="Created an exclude for this domain because we should manage it manually." +				autoCapitalize="sentences" +				rows={3} +			/> + +			<MutationButton +				label="Save" +				result={result} +				disabled={!form.domain.value || !form.domain.valid} +			/> +		</form> +	); +} diff --git a/web/source/settings/views/moderation/domain-permissions/overview.tsx b/web/source/settings/views/moderation/domain-permissions/overview.tsx index b2e675e05..b9a277e59 100644 --- a/web/source/settings/views/moderation/domain-permissions/overview.tsx +++ b/web/source/settings/views/moderation/domain-permissions/overview.tsx @@ -30,6 +30,7 @@ import type { MappedDomainPerms } from "../../../lib/types/domain-permission";  import { NoArg } from "../../../lib/types/query";  import { PermType } from "../../../lib/types/perm";  import { useBaseUrl } from "../../../lib/navigation/util"; +import { useCapitalize } from "../../../lib/util";  export default function DomainPermissionsOverview() {	  	const baseUrl = useBaseUrl(); @@ -42,9 +43,7 @@ export default function DomainPermissionsOverview() {  	const permType = params.permType.slice(0, -1) as PermType;  	// Uppercase first letter of given permType. -	const permTypeUpper = useMemo(() => { -		return permType.charAt(0).toUpperCase() + permType.slice(1);  -	}, [permType]); +	const permTypeUpper = useCapitalize(permType);  	// Fetch / wait for desired perms to load.  	const { data: blocks, isLoading: isLoadingBlocks } = useDomainBlocksQuery(NoArg, { skip: permType !== "block" }); diff --git a/web/source/settings/views/moderation/menu.tsx b/web/source/settings/views/moderation/menu.tsx index 9488b8c30..7ac6f9327 100644 --- a/web/source/settings/views/moderation/menu.tsx +++ b/web/source/settings/views/moderation/menu.tsx @@ -116,6 +116,40 @@ function ModerationDomainPermsMenu() {  				itemUrl="import-export"  				icon="fa-floppy-o"  			/> +			<MenuItem +				name="Drafts" +				itemUrl="drafts" +				defaultChild="search" +				icon="fa-pencil" +			> +				<MenuItem +					name="Search" +					itemUrl="search" +					icon="fa-list" +				/> +				<MenuItem +					name="New draft" +					itemUrl="new" +					icon="fa-plus" +				/> +			</MenuItem> +			<MenuItem +				name="Excludes" +				itemUrl="excludes" +				defaultChild="search" +				icon="fa-minus-square" +			> +				<MenuItem +					name="Search" +					itemUrl="search" +					icon="fa-list" +				/> +				<MenuItem +					name="New exclude" +					itemUrl="new" +					icon="fa-plus" +				/> +			</MenuItem>  		</MenuItem>  	);  } diff --git a/web/source/settings/views/moderation/reports/detail.tsx b/web/source/settings/views/moderation/reports/detail.tsx index 298b5bd37..b176b9f1e 100644 --- a/web/source/settings/views/moderation/reports/detail.tsx +++ b/web/source/settings/views/moderation/reports/detail.tsx @@ -25,7 +25,7 @@ import { useValue, useTextInput } from "../../../lib/form";  import useFormSubmit from "../../../lib/form/submit";  import { TextArea } from "../../../components/form/inputs";  import MutationButton from "../../../components/form/mutation-button"; -import Username from "../../../components/username"; +import UsernameLozenge from "../../../components/username-lozenge";  import { useGetReportQuery, useResolveReportMutation } from "../../../lib/query/admin/reports";  import { useBaseUrl } from "../../../lib/navigation/util";  import { AdminReport } from "../../../lib/types/report"; @@ -99,7 +99,7 @@ function ReportBasicInfo({ report, baseUrl, location }: ReportSectionProps) {  			<div className="info-list-entry">  				<dt>Reported account</dt>  				<dd> -					<Username +					<UsernameLozenge  						account={target}  						linkTo={`~/settings/moderation/accounts/${target.id}`}  						backLocation={`~${baseUrl}${location}`} @@ -110,7 +110,7 @@ function ReportBasicInfo({ report, baseUrl, location }: ReportSectionProps) {  			<div className="info-list-entry">  				<dt>Reported by</dt>  				<dd> -					<Username +					<UsernameLozenge  						account={from}  						linkTo={`~/settings/moderation/accounts/${from.id}`}  						backLocation={`~${baseUrl}${location}`} @@ -173,7 +173,7 @@ function ReportHistory({ report, baseUrl, location }: ReportSectionProps) {  				<div className="info-list-entry">  					<dt>Handled by</dt>  					<dd> -						<Username +						<UsernameLozenge  							account={handled_by}  							linkTo={`~/settings/moderation/accounts/${handled_by.id}`}  							backLocation={`~${baseUrl}${location}`} diff --git a/web/source/settings/views/moderation/reports/search.tsx b/web/source/settings/views/moderation/reports/search.tsx index da0c80d69..0ae3ec0e0 100644 --- a/web/source/settings/views/moderation/reports/search.tsx +++ b/web/source/settings/views/moderation/reports/search.tsx @@ -25,7 +25,7 @@ import { PageableList } from "../../../components/pageable-list";  import { Select } from "../../../components/form/inputs";  import MutationButton from "../../../components/form/mutation-button";  import { useLocation, useSearch } from "wouter"; -import Username from "../../../components/username"; +import UsernameLozenge from "../../../components/username-lozenge";  import { AdminReport } from "../../../lib/types/report";  export default function ReportsSearch() { @@ -206,7 +206,7 @@ function ReportListEntry({ report, linkTo, backLocation }: ReportEntryProps) {  				<div className="info-list-entry">  					<dt>Reported account:</dt>  					<dd className="text-cutoff"> -						<Username +						<UsernameLozenge  							account={target}  							classNames={["text-cutoff report-byline"]}  						/> @@ -216,7 +216,7 @@ function ReportListEntry({ report, linkTo, backLocation }: ReportEntryProps) {  				<div className="info-list-entry">  					<dt>Reported by:</dt>  					<dd className="text-cutoff reported-by"> -						<Username account={from} /> +						<UsernameLozenge account={from} />  					</dd>  				</div> diff --git a/web/source/settings/views/moderation/router.tsx b/web/source/settings/views/moderation/router.tsx index 93f7e481a..779498ffe 100644 --- a/web/source/settings/views/moderation/router.tsx +++ b/web/source/settings/views/moderation/router.tsx @@ -29,6 +29,12 @@ import DomainPermDetail from "./domain-permissions/detail";  import AccountsSearch from "./accounts";  import AccountsPending from "./accounts/pending";  import AccountDetail from "./accounts/detail"; +import DomainPermissionDraftsSearch from "./domain-permissions/drafts"; +import DomainPermissionDraftNew from "./domain-permissions/drafts/new"; +import DomainPermissionDraftDetail from "./domain-permissions/drafts/detail"; +import DomainPermissionExcludeDetail from "./domain-permissions/excludes/detail"; +import DomainPermissionExcludesSearch from "./domain-permissions/excludes"; +import DomainPermissionExcludeNew from "./domain-permissions/excludes/new";  /*  	EXPORTED COMPONENTS @@ -139,6 +145,12 @@ function ModerationDomainPermsRouter() {  					<Switch>  						<Route path="/import-export" component={ImportExport} />  						<Route path="/process" component={ImportExport} /> +						<Route path="/drafts/search" component={DomainPermissionDraftsSearch} /> +						<Route path="/drafts/new" component={DomainPermissionDraftNew} /> +						<Route path="/drafts/:permDraftId" component={DomainPermissionDraftDetail} /> +						<Route path="/excludes/search" component={DomainPermissionExcludesSearch} /> +						<Route path="/excludes/new" component={DomainPermissionExcludeNew} /> +						<Route path="/excludes/:excludeId" component={DomainPermissionExcludeDetail} />  						<Route path="/:permType" component={DomainPermissionsOverview} />  						<Route path="/:permType/:domain" component={DomainPermDetail} />  						<Route><Redirect to="/blocks"/></Route>  | 
