diff options
Diffstat (limited to 'web')
15 files changed, 1274 insertions, 87 deletions
| diff --git a/web/source/settings/components/form/inputs.tsx b/web/source/settings/components/form/inputs.tsx index c68095d95..e6c530b53 100644 --- a/web/source/settings/components/form/inputs.tsx +++ b/web/source/settings/components/form/inputs.tsx @@ -141,9 +141,28 @@ export interface SelectProps extends React.DetailedHTMLProps<  	field: TextFormInputHook;  	children?: ReactNode;  	options: React.JSX.Element; + +	/** +	 * Optional callback function that is +	 * triggered along with the select's onChange. +	 *  +	 * _selectValue is the current value of +	 * the select after onChange is triggered. +	 *  +	 * @param _selectValue  +	 * @returns  +	 */ +	onChangeCallback?: (_selectValue: string | undefined) => void;  } -export function Select({ label, field, children, options, ...props }: SelectProps) { +export function Select({ +	label, +	field, +	children, +	options, +	onChangeCallback, +	...props +}: SelectProps) {  	const { onChange, value, ref } = field;  	return ( @@ -152,7 +171,12 @@ export function Select({ label, field, children, options, ...props }: SelectProp  				{label}  				{children}  				<select -					onChange={onChange} +					onChange={(e: React.ChangeEvent<HTMLSelectElement>) => { +						onChange(e); +						if (onChangeCallback !== undefined) { +							onChangeCallback(e.target.value); +						} +					}}  					value={value}  					ref={ref as RefObject<HTMLSelectElement>}  					{...props} diff --git a/web/source/settings/lib/query/gts-api.ts b/web/source/settings/lib/query/gts-api.ts index f96a55fda..d6741df3a 100644 --- a/web/source/settings/lib/query/gts-api.ts +++ b/web/source/settings/lib/query/gts-api.ts @@ -141,6 +141,7 @@ export const gtsApi = createApi({  		"InstanceRules",  		"HTTPHeaderAllows",  		"HTTPHeaderBlocks", +		"DefaultInteractionPolicies",  	],  	endpoints: (build) => ({  		instanceV1: build.query<InstanceV1, void>({ diff --git a/web/source/settings/lib/query/user/index.ts b/web/source/settings/lib/query/user/index.ts index 1f9070bfb..0df926eb3 100644 --- a/web/source/settings/lib/query/user/index.ts +++ b/web/source/settings/lib/query/user/index.ts @@ -25,6 +25,7 @@ import type {  } from "../../types/migration";  import type { Theme } from "../../types/theme";  import { User } from "../../types/user"; +import { DefaultInteractionPolicies, UpdateDefaultInteractionPolicies } from "../../types/interaction";  const extended = gtsApi.injectEndpoints({  	endpoints: (build) => ({ @@ -38,9 +39,11 @@ const extended = gtsApi.injectEndpoints({  			}),  			...replaceCacheOnMutation("verifyCredentials")  		}), +		  		user: build.query<User, void>({  			query: () => ({url: `/api/v1/user`})  		}), +		  		passwordChange: build.mutation({  			query: (data) => ({  				method: "POST", @@ -48,6 +51,7 @@ const extended = gtsApi.injectEndpoints({  				body: data  			})  		}), +		  		emailChange: build.mutation<User, { password: string, new_email: string }>({  			query: (data) => ({  				method: "POST", @@ -56,6 +60,7 @@ const extended = gtsApi.injectEndpoints({  			}),  			...replaceCacheOnMutation("user")  		}), +		  		aliasAccount: build.mutation<any, UpdateAliasesFormData>({  			async queryFn(formData, _api, _extraOpts, fetchWithBQ) {  				// Pull entries out from the hooked form. @@ -73,6 +78,7 @@ const extended = gtsApi.injectEndpoints({  				});  			}  		}), +		  		moveAccount: build.mutation<any, MoveAccountFormData>({  			query: (data) => ({  				method: "POST", @@ -80,11 +86,37 @@ const extended = gtsApi.injectEndpoints({  				body: data  			})  		}), +  		accountThemes: build.query<Theme[], void>({  			query: () => ({  				url: `/api/v1/accounts/themes`  			}) -		}) +		}), + +		defaultInteractionPolicies: build.query<DefaultInteractionPolicies, void>({ +			query: () => ({ +				url: `/api/v1/interaction_policies/defaults` +			}), +			providesTags: ["DefaultInteractionPolicies"] +		}), + +		updateDefaultInteractionPolicies: build.mutation<DefaultInteractionPolicies, UpdateDefaultInteractionPolicies>({ +			query: (data) => ({ +				method: "PATCH", +				url: `/api/v1/interaction_policies/defaults`, +				body: data, +			}), +			...replaceCacheOnMutation("defaultInteractionPolicies") +		}), + +		resetDefaultInteractionPolicies: build.mutation<DefaultInteractionPolicies, void>({ +			query: () => ({ +				method: "PATCH", +				url: `/api/v1/interaction_policies/defaults`, +				body: {}, +			}), +			invalidatesTags: ["DefaultInteractionPolicies"] +		}),  	})  }); @@ -96,4 +128,7 @@ export const {  	useAliasAccountMutation,  	useMoveAccountMutation,  	useAccountThemesQuery, +	useDefaultInteractionPoliciesQuery, +	useUpdateDefaultInteractionPoliciesMutation, +	useResetDefaultInteractionPoliciesMutation,  } = extended; diff --git a/web/source/settings/lib/types/account.ts b/web/source/settings/lib/types/account.ts index 8fd4e0356..590b2c98e 100644 --- a/web/source/settings/lib/types/account.ts +++ b/web/source/settings/lib/types/account.ts @@ -64,6 +64,17 @@ export interface Account {  	enable_rss: boolean,  	role: any,  	suspended?: boolean, +	source?: AccountSource; +} + +export interface AccountSource { +	fields: any[]; +	follow_requests_count: number; +	language: string; +	note: string; +	privacy: string; +	sensitive: boolean; +	status_content_type: string;  }  export interface SearchAccountParams { diff --git a/web/source/settings/lib/types/interaction.ts b/web/source/settings/lib/types/interaction.ts new file mode 100644 index 000000000..735a20ed2 --- /dev/null +++ b/web/source/settings/lib/types/interaction.ts @@ -0,0 +1,63 @@ +/* +	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 DefaultInteractionPolicies { +	direct: InteractionPolicy; +	private: InteractionPolicy; +	unlisted: InteractionPolicy; +	public: InteractionPolicy; +} + +export interface UpdateDefaultInteractionPolicies { +	direct: InteractionPolicy | null; +	private: InteractionPolicy | null; +	unlisted: InteractionPolicy | null; +	public: InteractionPolicy | null; +} + +export interface InteractionPolicy { +	can_favourite: InteractionPolicyEntry; +	can_reply: InteractionPolicyEntry; +	can_reblog: InteractionPolicyEntry; +} + +export interface InteractionPolicyEntry { +	always: InteractionPolicyValue[]; +	with_approval: InteractionPolicyValue[]; +} + +export type InteractionPolicyValue = string; + +const PolicyValuePublic: InteractionPolicyValue = "public"; +const PolicyValueFollowers: InteractionPolicyValue = "followers"; +const PolicyValueFollowing: InteractionPolicyValue = "following"; +const PolicyValueMutuals: InteractionPolicyValue = "mutuals"; +const PolicyValueMentioned: InteractionPolicyValue = "mentioned"; +const PolicyValueAuthor: InteractionPolicyValue = "author"; +const PolicyValueMe: InteractionPolicyValue = "me"; + +export { +	PolicyValuePublic, +	PolicyValueFollowers, +	PolicyValueFollowing, +	PolicyValueMutuals, +	PolicyValueMentioned, +	PolicyValueAuthor, +	PolicyValueMe, +}; diff --git a/web/source/settings/style.css b/web/source/settings/style.css index f9c098ace..1cf723754 100644 --- a/web/source/settings/style.css +++ b/web/source/settings/style.css @@ -343,7 +343,7 @@ section.with-sidebar > form {  	.labelinput .border {  		border-radius: 0.2rem; -		border: 0.15rem solid $border_accent; +		border: 0.15rem solid $border-accent;  		padding: 0.3rem;  		display: flex;  		flex-direction: column; @@ -867,6 +867,41 @@ button.with-padding {  	padding: 0.5rem calc(0.5rem + $fa-fw);  } +.tab-buttons { +	display: flex; +	max-width: fit-content; +	justify-content: space-between; +	gap: 0.15rem; +} + +button.tab-button { +	border-top-left-radius: $br; +	border-top-right-radius: $br; +	border-bottom-left-radius: 0; +	border-bottom-right-radius: 0; +	box-shadow: none; +	background: $blue1; + +	&:hover { +		background: $button-hover-bg; +	} + +	text-overflow: ellipsis; +	overflow: hidden; +	white-space: nowrap; + +	font-size: 1rem; + +	@media screen and (max-width: 20rem) { +		font-size: 0.75rem; +	} + +	&.active { +		background: $button-bg; +		cursor: default; +	} +} +  .loading-icon {  	align-self: flex-start;  } @@ -1370,6 +1405,53 @@ button.with-padding {  	}  } +.interaction-default-settings {	 +	.interaction-policy-section { +		padding: 1rem; +		display: none; +		 +		&.active { +			display: flex; +		} +		 +		flex-direction: column; +		gap: 1rem; +		border: 0.15rem solid $input-border; + +		fieldset { +			display: flex; +			flex-direction: column; +			gap: 0.5rem; + +			margin: 0; +			padding: 0.5rem 1rem 1rem 1rem; + +			border: $boxshadow-border; +			border-radius: 0.1rem; +			box-shadow: $boxshadow; + +			>legend { +				display: flex; +				gap: 0.5rem; +				align-items: center; +				font-weight: bold; +				font-size: large; +			} + +			hr { +				width: 100%; +			} + +			.something-else { +				display: flex; +				flex-direction: column; +				gap: 0.5rem; +				margin-top: -0.3rem; +			} +		} +	} +} +  @media screen and (orientation: portrait) {  	.reports .report .byline {  		grid-template-columns: 1fr; diff --git a/web/source/settings/views/user/settings.tsx b/web/source/settings/views/user/emailpassword.tsx index 5696144a0..32df0e39d 100644 --- a/web/source/settings/views/user/settings.tsx +++ b/web/source/settings/views/user/emailpassword.tsx @@ -18,90 +18,21 @@  */  import React from "react"; -import { useTextInput, useBoolInput } from "../../lib/form"; +import { useTextInput } from "../../lib/form";  import useFormSubmit from "../../lib/form/submit"; -import { Select, TextInput, Checkbox } from "../../components/form/inputs"; -import FormWithData from "../../lib/form/form-with-data"; -import Languages from "../../components/languages"; +import { TextInput } from "../../components/form/inputs";  import MutationButton from "../../components/form/mutation-button"; -import { useVerifyCredentialsQuery } from "../../lib/query/oauth"; -import { useEmailChangeMutation, usePasswordChangeMutation, useUpdateCredentialsMutation, useUserQuery } from "../../lib/query/user"; +import { useEmailChangeMutation, usePasswordChangeMutation, useUserQuery } from "../../lib/query/user";  import Loading from "../../components/loading";  import { User } from "../../lib/types/user";  import { useInstanceV1Query } from "../../lib/query/gts-api"; -export default function UserSettings() { -	return ( -		<FormWithData -			dataQuery={useVerifyCredentialsQuery} -			DataForm={UserSettingsForm} -		/> -	); -} - -function UserSettingsForm({ data }) { -	/* form keys -		- string source[privacy] -		- bool source[sensitive] -		- string source[language] -		- string source[status_content_type] -	 */ - -	const form = { -		defaultPrivacy: useTextInput("source[privacy]", { source: data, defaultValue: "unlisted" }), -		isSensitive: useBoolInput("source[sensitive]", { source: data }), -		language: useTextInput("source[language]", { source: data, valueSelector: (s) => s.source.language?.toUpperCase() ?? "EN" }), -		statusContentType: useTextInput("source[status_content_type]", { source: data, defaultValue: "text/plain" }), -	}; - -	const [submitForm, result] = useFormSubmit(form, useUpdateCredentialsMutation()); - +export default function EmailPassword() {  	return (  		<> -			<h1>Account Settings</h1> -			<form className="user-settings" onSubmit={submitForm}> -				<div className="form-section-docs"> -					<h3>Post Settings</h3> -					<a -						href="https://docs.gotosocial.org/en/latest/user_guide/posts" -						target="_blank" -						className="docslink" -						rel="noreferrer" -					> -						Learn more about these settings (opens in a new tab) -					</a> -				</div> -				<Select field={form.language} label="Default post language" options={ -					<Languages /> -				}> -				</Select> -				<Select field={form.defaultPrivacy} label="Default post privacy" options={ -					<> -						<option value="private">Private / followers-only</option> -						<option value="unlisted">Unlisted</option> -						<option value="public">Public</option> -					</> -				}> -				</Select> -				<Select field={form.statusContentType} label="Default post (and bio) format" options={ -					<> -						<option value="text/plain">Plain (default)</option> -						<option value="text/markdown">Markdown</option> -					</> -				}> -				</Select> -				<Checkbox -					field={form.isSensitive} -					label="Mark my posts as sensitive by default" -				/> -				<MutationButton -					disabled={false} -					label="Save settings" -					result={result} -				/> -			</form> -			<PasswordChange /> +			<h1>Email & Password Settings</h1>  			<EmailChange /> +			<PasswordChange />  		</>  	);  } @@ -330,4 +261,4 @@ function EmailChangeForm({user, oidcEnabled}: { user: User, oidcEnabled?: boolea  			/>  		</form>  	); -} +}
\ No newline at end of file diff --git a/web/source/settings/views/user/menu.tsx b/web/source/settings/views/user/menu.tsx index 578bd8ae0..3d90bfe21 100644 --- a/web/source/settings/views/user/menu.tsx +++ b/web/source/settings/views/user/menu.tsx @@ -22,7 +22,8 @@ import React from "react";  /**   * - /settings/user/profile - * - /settings/user/settings + * - /settings/user/posts + * - /settings/user/emailpassword   * - /settings/user/migration   */  export default function UserMenu() {	 @@ -38,9 +39,14 @@ export default function UserMenu() {  				icon="fa-user"  			/>  			<MenuItem -				name="Settings" -				itemUrl="settings" -				icon="fa-cogs" +				name="Posts" +				itemUrl="posts" +				icon="fa-paper-plane" +			/> +			<MenuItem +				name="Email & Password" +				itemUrl="emailpassword" +				icon="fa-user-secret"  			/>  			<MenuItem  				name="Migration" diff --git a/web/source/settings/views/user/posts/basic-settings/index.tsx b/web/source/settings/views/user/posts/basic-settings/index.tsx new file mode 100644 index 000000000..a3c6a8a65 --- /dev/null +++ b/web/source/settings/views/user/posts/basic-settings/index.tsx @@ -0,0 +1,88 @@ +/* +	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 { useTextInput, useBoolInput } from "../../../../lib/form"; +import useFormSubmit from "../../../../lib/form/submit"; +import { Select, Checkbox } from "../../../../components/form/inputs"; +import Languages from "../../../../components/languages"; +import MutationButton from "../../../../components/form/mutation-button"; +import { useUpdateCredentialsMutation } from "../../../../lib/query/user"; +import { Account } from "../../../../lib/types/account"; + +export default function BasicSettings({ account }: { account: Account }) { +	/* form keys +		- string source[privacy] +		- bool source[sensitive] +		- string source[language] +		- string source[status_content_type] +	 */ +	const form = { +		defaultPrivacy: useTextInput("source[privacy]", { source: account, defaultValue: "unlisted" }), +		isSensitive: useBoolInput("source[sensitive]", { source: account }), +		language: useTextInput("source[language]", { source: account, valueSelector: (s: Account) => s.source?.language?.toUpperCase() ?? "EN" }), +		statusContentType: useTextInput("source[status_content_type]", { source: account, defaultValue: "text/plain" }), +	}; +	 +	const [submitForm, result] = useFormSubmit(form, useUpdateCredentialsMutation()); +	 +	return ( +		<form className="post-settings" onSubmit={submitForm}> +			<div className="form-section-docs"> +				<h3>Basic</h3> +				<a +					href="https://docs.gotosocial.org/en/latest/user_guide/settings#post-settings" +					target="_blank" +					className="docslink" +					rel="noreferrer" +				> +				Learn more about these settings (opens in a new tab) +				</a> +			</div> +			<Select field={form.language} label="Default post language" options={ +				<Languages /> +			}> +			</Select> +			<Select field={form.defaultPrivacy} label="Default post privacy" options={ +				<> +					<option value="public">Public</option> +					<option value="unlisted">Unlisted</option> +					<option value="private">Followers-only</option> +				</> +			}> +			</Select> +			<Select field={form.statusContentType} label="Default post (and bio) format" options={ +				<> +					<option value="text/plain">Plain (default)</option> +					<option value="text/markdown">Markdown</option> +				</> +			}> +			</Select> +			<Checkbox +				field={form.isSensitive} +				label="Mark my posts as sensitive by default" +			/> +			<MutationButton +				disabled={false} +				label="Save settings" +				result={result} +			/> +		</form> +	); +}
\ No newline at end of file diff --git a/web/source/settings/views/user/posts/index.tsx b/web/source/settings/views/user/posts/index.tsx new file mode 100644 index 000000000..4d7669391 --- /dev/null +++ b/web/source/settings/views/user/posts/index.tsx @@ -0,0 +1,51 @@ +/* +	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 { useVerifyCredentialsQuery } from "../../../lib/query/oauth"; +import Loading from "../../../components/loading"; +import { Error } from "../../../components/error"; +import BasicSettings from "./basic-settings"; +import InteractionPolicySettings from "./interaction-policy-settings"; + +export default function PostSettings() { +	const { +		data: account, +		isLoading, +		isFetching, +		isError, +		error, +	} = useVerifyCredentialsQuery(); + +	if (isLoading || isFetching) { +		return <Loading />; +	} + +	if (isError) { +		return <Error error={error} />; +	} + +	return ( +		<> +			<h1>Post Settings</h1> +			<BasicSettings account={account} /> +			<InteractionPolicySettings /> +		</> +	); +} diff --git a/web/source/settings/views/user/posts/interaction-policy-settings/basic.tsx b/web/source/settings/views/user/posts/interaction-policy-settings/basic.tsx new file mode 100644 index 000000000..8d229a3e0 --- /dev/null +++ b/web/source/settings/views/user/posts/interaction-policy-settings/basic.tsx @@ -0,0 +1,180 @@ +/* +	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, { useMemo } from "react"; +import { +	InteractionPolicyValue, +	PolicyValueAuthor, +	PolicyValueFollowers, +	PolicyValueMentioned, +	PolicyValuePublic, +} from "../../../../lib/types/interaction"; +import { useTextInput } from "../../../../lib/form"; +import { Action, BasicValue, PolicyFormSub, Visibility } from "./types"; + +// Based on the given visibility, action, and states, +// derives what the initial basic Select value should be. +function useBasicValue( +	forVis: Visibility, +	forAction: Action, +	always: InteractionPolicyValue[], +	withApproval: InteractionPolicyValue[], +): BasicValue { +	// Check if "always" value is just the author +	// (and possibly mentioned accounts when dealing +	// with replies -- still counts as "just_me"). +	const alwaysJustAuthor = useMemo(() => { +		if ( +			always.length === 1 && +			always[0] === PolicyValueAuthor +		) { +			return true; +		} + +		if ( +			forAction === "reply" && +			always.length === 2 && +			always.includes(PolicyValueAuthor) && +			always.includes(PolicyValueMentioned) +		) { +			return true; +		} + +		return false; +	}, [forAction, always]); + +	// Check if "always" includes the widest +	// possible audience for this visibility. +	const alwaysWidestAudience = useMemo(() => { +		return ( +			(forVis === "private" && always.includes(PolicyValueFollowers)) || +			always.includes(PolicyValuePublic) +		); +	}, [forVis, always]); + +	// Check if "withApproval" includes the widest +	// possible audience for this visibility. +	const withApprovalWidestAudience = useMemo(() => { +		return ( +			(forVis === "private" && withApproval.includes(PolicyValueFollowers)) || +			withApproval.includes(PolicyValuePublic) +		); +	}, [forVis, withApproval]); + +	return useMemo(() => { +		// Simplest case: if "always" includes the +		// widest possible audience for this visibility, +		// then we don't need to check anything else. +		if (alwaysWidestAudience) { +			return "anyone"; +		} + +		// Next simplest case: there's no "with approval" +		// URIs set, so check if it's always just author. +		if (withApproval.length === 0 && alwaysJustAuthor) { +			return "just_me"; +		} + +		// Third simplest case: always is just us, and with +		// approval is addressed to the widest possible audience. +		if (alwaysJustAuthor && withApprovalWidestAudience) { +			return "anyone_with_approval"; +		} + +		// We've exhausted the +		// simple possibilities. +		return "something_else"; +	}, [ +		withApproval.length, +		alwaysJustAuthor, +		alwaysWidestAudience, +		withApprovalWidestAudience, +	]); +} + +// Derive wording for the basic label for  +// whatever visibility and action we're handling. +function useBasicLabel(visibility: Visibility, action: Action) { +	return useMemo(() => { +		let visPost = ""; +		switch (visibility) { +			case "public": +				visPost = "a public post"; +				break; +			case "unlisted": +				visPost = "an unlisted post"; +				break; +			case "private": +				visPost = "a followers-only post"; +				break; +		} +		 +		switch (action) { +			case "favourite": +				return "Who can like " + visPost + "?"; +			case "reply": +				return "Who else can reply to " + visPost + "?"; +			case "reblog": +				return "Who can boost " + visPost + "?"; +		} +	}, [visibility, action]); +} + +// Return whatever the "basic" options should +// be in the basic Select for this visibility. +function useBasicOptions(visibility: Visibility) { +	return useMemo(() => { +		const audience = visibility === "private" +			? "My followers" +			: "Anyone"; +		 +		return ( +			<> +				<option value="anyone">{audience}</option> +				<option value="anyone_with_approval">{audience} (approval required)</option> +				<option value="just_me">Just me</option> +				{ visibility !== "private" && +					<option value="something_else">Something else...</option> +				} +			</> +		); +	}, [visibility]); +} + +export function useBasicFor( +	forVis: Visibility, +	forAction: Action, +	currentAlways: InteractionPolicyValue[], +	currentWithApproval: InteractionPolicyValue[], +): PolicyFormSub { +	// Determine who's currently *basically* allowed +	// to do this action for this visibility. +	const defaultValue = useBasicValue( +		forVis, +		forAction, +		currentAlways, +		currentWithApproval, +	); + +	return { +		field: useTextInput("basic", { defaultValue: defaultValue }), +		label: useBasicLabel(forVis, forAction), +		options: useBasicOptions(forVis), +	}; +} diff --git a/web/source/settings/views/user/posts/interaction-policy-settings/index.tsx b/web/source/settings/views/user/posts/interaction-policy-settings/index.tsx new file mode 100644 index 000000000..143cf0865 --- /dev/null +++ b/web/source/settings/views/user/posts/interaction-policy-settings/index.tsx @@ -0,0 +1,553 @@ +/* +	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, { useCallback, useMemo } from "react"; +import { +	useDefaultInteractionPoliciesQuery, +	useResetDefaultInteractionPoliciesMutation, +	useUpdateDefaultInteractionPoliciesMutation, +} from "../../../../lib/query/user"; +import Loading from "../../../../components/loading"; +import { Error } from "../../../../components/error"; +import MutationButton from "../../../../components/form/mutation-button"; +import { +	DefaultInteractionPolicies, +	InteractionPolicy, +	InteractionPolicyEntry, +	InteractionPolicyValue, +	PolicyValueAuthor, +	PolicyValueFollowers, +	PolicyValueFollowing, +	PolicyValueMentioned, +	PolicyValuePublic, +} from "../../../../lib/types/interaction"; +import { useTextInput } from "../../../../lib/form"; +import { Select } from "../../../../components/form/inputs"; +import { TextFormInputHook } from "../../../../lib/form/types"; +import { useBasicFor } from "./basic"; +import { PolicyFormSomethingElse, useSomethingElseFor } from "./something-else"; +import { Action, PolicyFormSub, SomethingElseValue, Visibility } from "./types"; + +export default function InteractionPolicySettings() { +	const { +		data: defaultPolicies, +		isLoading, +		isFetching, +		isError, +		error, +	} = useDefaultInteractionPoliciesQuery(); + +	if (isLoading || isFetching) { +		return <Loading />; +	} + +	if (isError) { +		return <Error error={error} />; +	} + +	if (!defaultPolicies) { +		throw "default policies undefined"; +	} + +	return ( +		<InteractionPoliciesForm defaultPolicies={defaultPolicies} /> +	); +} + +interface InteractionPoliciesFormProps { +	defaultPolicies: DefaultInteractionPolicies; +} + +function InteractionPoliciesForm({ defaultPolicies }: InteractionPoliciesFormProps) { +	// Sub-form for visibility "public". +	const formPublic = useFormForVis(defaultPolicies.public, "public"); +	const assemblePublic = useCallback(() => { +		return { +			can_favourite: assemblePolicyEntry("public", "favourite", formPublic), +			can_reply: assemblePolicyEntry("public", "reply", formPublic), +			can_reblog: assemblePolicyEntry("public", "reblog", formPublic), +		}; +	}, [formPublic]); +	 +	// Sub-form for visibility "unlisted". +	const formUnlisted = useFormForVis(defaultPolicies.unlisted, "unlisted"); +	const assembleUnlisted = useCallback(() => { +		return { +			can_favourite: assemblePolicyEntry("unlisted", "favourite", formUnlisted), +			can_reply: assemblePolicyEntry("unlisted", "reply", formUnlisted), +			can_reblog: assemblePolicyEntry("unlisted", "reblog", formUnlisted), +		}; +	}, [formUnlisted]); +	 +	// Sub-form for visibility "private". +	const formPrivate = useFormForVis(defaultPolicies.private, "private"); +	const assemblePrivate = useCallback(() => { +		return { +			can_favourite: assemblePolicyEntry("private", "favourite", formPrivate), +			can_reply: assemblePolicyEntry("private", "reply", formPrivate), +			can_reblog: assemblePolicyEntry("private", "reblog", formPrivate), +		}; +	}, [formPrivate]); + +	const selectedVis = useTextInput("selectedVis", { defaultValue: "public" }); +	 +	const [updatePolicies, updateResult] = useUpdateDefaultInteractionPoliciesMutation(); +	const [resetPolicies, resetResult] = useResetDefaultInteractionPoliciesMutation(); + +	const onSubmit = (e) => { +		e.preventDefault(); +		updatePolicies({ +			public: assemblePublic(), +			unlisted: assembleUnlisted(), +			private: assemblePrivate(), +			// Always use the +			// default for direct. +			direct: null, +		}); +	}; + +	return ( +		<form className="interaction-default-settings" onSubmit={onSubmit}> +			<div className="form-section-docs"> +				<h3>Default Interaction Policies</h3> +				<p> +					You can use this section to customize the default interaction +					policy for posts created by you, per visibility setting. +					<br/> +					These settings apply only for new posts created by you <em>after</em> applying +					these settings; they do not apply retroactively. +					<br/> +					The word "anyone" in the below options means <em>anyone with +					permission to see the post</em>, taking account of blocks. +					<br/> +					Bear in mind that no matter what you set below, you will always +					be able to like, reply-to, and boost your own posts. +				</p> +				<a +					href="https://docs.gotosocial.org/en/latest/user_guide/settings#default-interaction-policies" +					target="_blank" +					className="docslink" +					rel="noreferrer" +				> +					Learn more about these settings (opens in a new tab) +				</a> +			</div> +			<div className="tabbable-sections"> +				<PolicyPanelsTablist selectedVis={selectedVis} /> +				<PolicyPanel +					policyForm={formPublic} +					forVis={"public"} +					isActive={selectedVis.value === "public"} +				/> +				<PolicyPanel +					policyForm={formUnlisted} +					forVis={"unlisted"} +					isActive={selectedVis.value === "unlisted"} +				/> +				<PolicyPanel +					policyForm={formPrivate} +					forVis={"private"} +					isActive={selectedVis.value === "private"} +				/> +			</div> + +			<div className="action-buttons row"> +				<MutationButton +					disabled={false} +					label="Save policies" +					result={updateResult} +				/> + +				<MutationButton +					disabled={false} +					type="button" +					onClick={() => resetPolicies()} +					label="Reset to defaults" +					result={resetResult} +					className="button danger" +					showError={false} +				/> +			</div> + +		</form> +	); +} + +// A tablist of tab buttons, one for each visibility. +function PolicyPanelsTablist({ selectedVis }: { selectedVis: TextFormInputHook}) { +	return ( +		<div className="tab-buttons" role="tablist"> +			<Tab +				thisVisibility="public" +				label="Public" +				selectedVis={selectedVis} +			/> +			<Tab +				thisVisibility="unlisted" +				label="Unlisted" +				selectedVis={selectedVis} +			/> +			<Tab +				thisVisibility="private" +				label="Followers-only" +				selectedVis={selectedVis} +			/> +		</div> +	); +} + +interface TabProps { +	thisVisibility: string; +	label: string, +	selectedVis: TextFormInputHook +} + +// One tab in a tablist, corresponding to the given thisVisibility. +function Tab({ thisVisibility, label, selectedVis }: TabProps) { +	const selected = useMemo(() => { +		return selectedVis.value === thisVisibility; +	}, [selectedVis, thisVisibility]); + +	return ( +		<button +			id={`tab-${thisVisibility}`} +			title={label} +			role="tab" +			className={`tab-button ${selected && "active"}`} +			onClick={(e) => { +				e.preventDefault(); +				selectedVis.setter(thisVisibility); +			}} +			aria-selected={selected} +			aria-controls={`panel-${thisVisibility}`} +			tabIndex={selected ? 0 : -1} +		> +			{label} +		</button> +	); +} + +interface PolicyPanelProps { +	policyForm: PolicyForm; +	forVis: Visibility; +	isActive: boolean; +} + +// Tab panel for one policy form of the given visibility. +function PolicyPanel({ policyForm, forVis, isActive }: PolicyPanelProps) { +	return ( +		<div +			className={`interaction-policy-section ${isActive && "active"}`} +			role="tabpanel" +			hidden={!isActive} +		> +			<PolicyComponent +				form={policyForm.favourite} +				forAction="favourite" +			/> +			<PolicyComponent +				form={policyForm.reply} +				forAction="reply" +			/> +			{ forVis !== "private" && +				<PolicyComponent +					form={policyForm.reblog} +					forAction="reblog" +				/> +			} +		</div> +	); +} + +interface PolicyComponentProps { +	form: { +		basic: PolicyFormSub; +		somethingElse: PolicyFormSomethingElse; +	}; +	forAction: Action; +} + +// A component of one policy of the given +// visibility, corresponding to the given action. +function PolicyComponent({ form, forAction }: PolicyComponentProps) {	 +	const legend = useLegend(forAction); +	return ( +		<fieldset> +			<legend>{legend}</legend> +			{ forAction === "reply" && +				<div className="info"> +					<i className="fa fa-fw fa-info-circle" aria-hidden="true"></i> +					<b>Mentioned accounts can always reply.</b> +				</div>	 +			} +			<Select +				field={form.basic.field} +				label={form.basic.label} +				options={form.basic.options} +			/> +			{/* Include advanced "something else" options if appropriate */} +			{ (form.basic.field.value === "something_else") && +				<> +					<hr /> +					<div className="something-else"> +						<Select +							field={form.somethingElse.followers.field} +							label={form.somethingElse.followers.label} +							options={form.somethingElse.followers.options} +						/> +						<Select +							field={form.somethingElse.following.field} +							label={form.somethingElse.following.label} +							options={form.somethingElse.following.options} +						/> +						{/* +							Skip mentioned accounts field for reply action, +							since mentioned accounts can always reply. +						*/} +						{ forAction !== "reply" && +							<Select +								field={form.somethingElse.mentioned.field} +								label={form.somethingElse.mentioned.label} +								options={form.somethingElse.mentioned.options} +							/> +						} +						<Select +							field={form.somethingElse.everyoneElse.field} +							label={form.somethingElse.everyoneElse.label} +							options={form.somethingElse.everyoneElse.options} +						/> +					</div> +				</> +			} +		</fieldset> +	); +} + +/* +	UTILITY FUNCTIONS +*/ + +// useLegend returns an appropriate +// fieldset legend for the given action. +function useLegend(action: Action) { +	return useMemo(() => { +		switch (action) { +			case "favourite": +				return ( +					<> +						<i className="fa fa-fw fa-star" aria-hidden="true"></i> +						<span>Like</span> +					</> +				); +			case "reply": +				return ( +					<> +						<i className="fa fa-fw fa-reply-all" aria-hidden="true"></i> +						<span>Reply</span> +					</> +				); +			case "reblog": +				return ( +					<> +						<i className="fa fa-fw fa-retweet" aria-hidden="true"></i> +						<span>Boost</span> +					</> +				); +		} +	}, [action]); +} + +// Form encapsulating the different +// actions for one visibility. +interface PolicyForm { +	favourite: { +		basic: PolicyFormSub, +		somethingElse: PolicyFormSomethingElse, +	} +	reply: { +		basic: PolicyFormSub, +		somethingElse: PolicyFormSomethingElse, +	} +	reblog: { +		basic: PolicyFormSub, +		somethingElse: PolicyFormSomethingElse, +	} +} + +// Return a PolicyForm for the given visibility, +// set already to whatever the defaultPolicies value is. +function useFormForVis( +	currentPolicy: InteractionPolicy, +	forVis: Visibility, +): PolicyForm {	 +	return { +		favourite: { +			basic: useBasicFor( +				forVis, +				"favourite", +				currentPolicy.can_favourite.always, +				currentPolicy.can_favourite.with_approval, +			), +			somethingElse: useSomethingElseFor( +				forVis, +				"favourite", +				currentPolicy.can_favourite.always, +				currentPolicy.can_favourite.with_approval, +			), +		}, +		reply: { +			basic: useBasicFor( +				forVis, +				"reply", +				currentPolicy.can_reply.always, +				currentPolicy.can_reply.with_approval, +			), +			somethingElse: useSomethingElseFor( +				forVis, +				"reply", +				currentPolicy.can_reply.always, +				currentPolicy.can_reply.with_approval, +			), +		}, +		reblog: { +			basic: useBasicFor( +				forVis, +				"reblog", +				currentPolicy.can_reblog.always, +				currentPolicy.can_reblog.with_approval, +			), +			somethingElse: useSomethingElseFor( +				forVis, +				"reblog", +				currentPolicy.can_reblog.always, +				currentPolicy.can_reblog.with_approval, +			), +		}, +	}; +} + +function assemblePolicyEntry( +	forVis: Visibility, +	forAction: Action, +	policyForm: PolicyForm, +): InteractionPolicyEntry { +	const basic = policyForm[forAction].basic; +	 +	// If this is followers visibility then +	// "anyone" only means followers, not public. +	const anyone: InteractionPolicyValue = +		(forVis === "private") +			? PolicyValueFollowers +			: PolicyValuePublic; +	 +	// If this is a reply action then "just me" +	// must include mentioned accounts as well, +	// since they can always reply. +	const justMe: InteractionPolicyValue[] = +		(forAction === "reply") +			? [PolicyValueAuthor, PolicyValueMentioned] +			: [PolicyValueAuthor]; + +	switch (basic.field.value) { +		case "anyone": +			return { +				// Anyone can do this. +				always: [anyone], +				with_approval: [], +			}; +		case "anyone_with_approval": +			return { +				// Author and maybe mentioned can do +				// this, everyone else needs approval. +				always: justMe, +				with_approval: [anyone], +			}; +		case "just_me": +			return { +				// Only author and maybe +				// mentioned can do this. +				always: justMe, +				with_approval: [], +			}; +	} + +	// Something else! +	const somethingElse = policyForm[forAction].somethingElse; +	 +	// Start with basic "always" +	// and "with_approval" values. +	let always: InteractionPolicyValue[] = justMe; +	let withApproval: InteractionPolicyValue[] = []; +	 +	// Add PolicyValueFollowers depending on choices made. +	switch (somethingElse.followers.field.value as SomethingElseValue) { +		case "always": +			always.push(PolicyValueFollowers); +			break; +		case "with_approval": +			withApproval.push(PolicyValueFollowers); +			break; +	} + +	// Add PolicyValueFollowing depending on choices made. +	switch (somethingElse.following.field.value as SomethingElseValue) { +		case "always": +			always.push(PolicyValueFollowing); +			break; +		case "with_approval": +			withApproval.push(PolicyValueFollowing); +			break; +	} + +	// Add PolicyValueMentioned depending on choices made. +	// Note: mentioned can always reply, and that's already +	// included above, so only do this if action is not reply. +	if (forAction !== "reply") { +		switch (somethingElse.mentioned.field.value as SomethingElseValue) { +			case "always": +				always.push(PolicyValueMentioned); +				break; +			case "with_approval": +				withApproval.push(PolicyValueMentioned); +				break; +		} +	} + +	// Add anyone depending on choices made. +	switch (somethingElse.everyoneElse.field.value as SomethingElseValue) { +		case "with_approval": +			withApproval.push(anyone); +			break; +	} + +	// Simplify a bit after +	// all the parsing above. +	if (always.includes(anyone)) { +		always = [anyone]; +	} + +	if (withApproval.includes(anyone)) { +		withApproval = [anyone]; +	} + +	return { +		always: always, +		with_approval: withApproval, +	}; +} diff --git a/web/source/settings/views/user/posts/interaction-policy-settings/something-else.tsx b/web/source/settings/views/user/posts/interaction-policy-settings/something-else.tsx new file mode 100644 index 000000000..8882060c4 --- /dev/null +++ b/web/source/settings/views/user/posts/interaction-policy-settings/something-else.tsx @@ -0,0 +1,124 @@ +/* +	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, { useMemo } from "react"; +import { InteractionPolicyValue, PolicyValueFollowers, PolicyValueFollowing, PolicyValuePublic } from "../../../../lib/types/interaction"; +import { useTextInput } from "../../../../lib/form"; +import { Action, Audience, PolicyFormSub, SomethingElseValue, Visibility } from "./types"; + +export interface PolicyFormSomethingElse { +	followers: PolicyFormSub, +	following: PolicyFormSub, +	mentioned: PolicyFormSub, +	everyoneElse: PolicyFormSub, +} + +function useSomethingElseOptions( +	forVis: Visibility, +	forAction: Action, +	forAudience: Audience, +) { +	return ( +		<> +			{ forAudience !== "everyone_else" && +				<option value="always">Always</option> +			} +			<option value="with_approval">With my approval</option> +			<option value="no">No</option> +		</> +	); +} + +export function useSomethingElseFor( +	forVis: Visibility, +	forAction: Action, +	currentAlways: InteractionPolicyValue[], +	currentWithApproval: InteractionPolicyValue[], +): PolicyFormSomethingElse {	 +	const followersDefaultValue: SomethingElseValue = useMemo(() => { +		if (currentAlways.includes(PolicyValueFollowers)) { +			return "always"; +		} + +		if (currentWithApproval.includes(PolicyValueFollowers)) { +			return "with_approval"; +		} +		 +		return "no"; +	}, [currentAlways, currentWithApproval]); +	 +	const followingDefaultValue: SomethingElseValue = useMemo(() => { +		if (currentAlways.includes(PolicyValueFollowing)) { +			return "always"; +		} + +		if (currentWithApproval.includes(PolicyValueFollowing)) { +			return "with_approval"; +		} +		 +		return "no"; +	}, [currentAlways, currentWithApproval]); +	 +	const mentionedDefaultValue: SomethingElseValue = useMemo(() => { +		if (currentAlways.includes(PolicyValueFollowing)) { +			return "always"; +		} + +		if (currentWithApproval.includes(PolicyValueFollowing)) { +			return "with_approval"; +		} +		 +		return "no"; +	}, [currentAlways, currentWithApproval]); +	 +	const everyoneElseDefaultValue: SomethingElseValue = useMemo(() => { +		if (currentAlways.includes(PolicyValuePublic)) { +			return "always"; +		} + +		if (currentWithApproval.includes(PolicyValuePublic)) { +			return "with_approval"; +		} +		 +		return "no"; +	}, [currentAlways, currentWithApproval]); + +	return { +		followers: { +			field: useTextInput("followers", { defaultValue: followersDefaultValue }), +			label: "My followers", +			options: useSomethingElseOptions(forVis, forAction, "followers"), +		}, +		following: { +			field: useTextInput("following", { defaultValue: followingDefaultValue }), +			label: "Accounts I follow", +			options: useSomethingElseOptions(forVis, forAction, "following"), +		}, +		mentioned: { +			field: useTextInput("mentioned_accounts", { defaultValue: mentionedDefaultValue }), +			label: "Accounts mentioned in the post", +			options: useSomethingElseOptions(forVis, forAction, "mentioned_accounts"), +		}, +		everyoneElse: { +			field: useTextInput("everyone_else", { defaultValue: everyoneElseDefaultValue }), +			label: "Everyone else", +			options: useSomethingElseOptions(forVis, forAction, "everyone_else"), +		}, +	}; +}
\ No newline at end of file diff --git a/web/source/settings/views/user/posts/interaction-policy-settings/types.ts b/web/source/settings/views/user/posts/interaction-policy-settings/types.ts new file mode 100644 index 000000000..d523366ee --- /dev/null +++ b/web/source/settings/views/user/posts/interaction-policy-settings/types.ts @@ -0,0 +1,35 @@ +/* +	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 { TextFormInputHook } from "../../../../lib/form/types"; +import React from "react"; + +export interface PolicyFormSub { +	field: TextFormInputHook; +	label: string; +	options: React.JSX.Element; +} + +/* Form / select types */ + +export type Visibility = "public" | "unlisted" | "private";  +export type Action = "favourite" | "reply" | "reblog"; +export type BasicValue = "anyone" | "anyone_with_approval" | "just_me" | "something_else"; +export type SomethingElseValue = "always" | "with_approval" | "no"; +export type Audience = "followers" | "following" | "mentioned_accounts" | "everyone_else"; diff --git a/web/source/settings/views/user/router.tsx b/web/source/settings/views/user/router.tsx index e763c0c2b..5b74aee68 100644 --- a/web/source/settings/views/user/router.tsx +++ b/web/source/settings/views/user/router.tsx @@ -23,11 +23,13 @@ import { Redirect, Route, Router, Switch } from "wouter";  import { ErrorBoundary } from "../../lib/navigation/error";  import UserProfile from "./profile";  import UserMigration from "./migration"; -import UserSettings from "./settings"; +import PostSettings from "./posts"; +import EmailPassword from "./emailpassword";  /**   * - /settings/user/profile - * - /settings/user/settings + * - /settings/user/posts + * - /settings/user/emailpassword   * - /settings/user/migration   */  export default function UserRouter() { @@ -41,7 +43,8 @@ export default function UserRouter() {  				<ErrorBoundary>  					<Switch>  						<Route path="/profile" component={UserProfile} /> -						<Route path="/settings" component={UserSettings} /> +						<Route path="/posts" component={PostSettings} /> +						<Route path="/emailpassword" component={EmailPassword} />  						<Route path="/migration" component={UserMigration} />  						<Route><Redirect to="/profile" /></Route>  					</Switch> | 
