From 0aadc2db2a42fc99538fbbb096b84b209b9ccd68 Mon Sep 17 00:00:00 2001 From: tobi <31960611+tsmethurst@users.noreply.github.com> Date: Wed, 17 Jul 2024 16:46:52 +0200 Subject: [feature] Allow users to set default interaction policies per status visibility (#3108) * [feature] Allow users to set default interaction policies * use vars for default policies * avoid some code repetition * unfuck form binding * avoid bonkers loop * beep boop * put policyValsToAPIPolicyVals in separate function * don't bother with slices.Grow * oops --- .../views/user/posts/basic-settings/index.tsx | 88 ++++ web/source/settings/views/user/posts/index.tsx | 51 ++ .../posts/interaction-policy-settings/basic.tsx | 180 +++++++ .../posts/interaction-policy-settings/index.tsx | 553 +++++++++++++++++++++ .../interaction-policy-settings/something-else.tsx | 124 +++++ .../posts/interaction-policy-settings/types.ts | 35 ++ 6 files changed, 1031 insertions(+) create mode 100644 web/source/settings/views/user/posts/basic-settings/index.tsx create mode 100644 web/source/settings/views/user/posts/index.tsx create mode 100644 web/source/settings/views/user/posts/interaction-policy-settings/basic.tsx create mode 100644 web/source/settings/views/user/posts/interaction-policy-settings/index.tsx create mode 100644 web/source/settings/views/user/posts/interaction-policy-settings/something-else.tsx create mode 100644 web/source/settings/views/user/posts/interaction-policy-settings/types.ts (limited to 'web/source/settings/views/user/posts') 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 . +*/ + +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 ( +
+
+

Basic

+ + Learn more about these settings (opens in a new tab) + +
+ + + + + + + ); +} \ 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 . +*/ + +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 ; + } + + if (isError) { + return ; + } + + return ( + <> +

Post Settings

+ + + + ); +} 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 . +*/ + +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 ( + <> + + + + { visibility !== "private" && + + } + + ); + }, [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 . +*/ + +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 ; + } + + if (isError) { + return ; + } + + if (!defaultPolicies) { + throw "default policies undefined"; + } + + return ( + + ); +} + +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 ( +
+
+

Default Interaction Policies

+

+ You can use this section to customize the default interaction + policy for posts created by you, per visibility setting. +
+ These settings apply only for new posts created by you after applying + these settings; they do not apply retroactively. +
+ The word "anyone" in the below options means anyone with + permission to see the post, taking account of blocks. +
+ Bear in mind that no matter what you set below, you will always + be able to like, reply-to, and boost your own posts. +

+ + Learn more about these settings (opens in a new tab) + +
+
+ + + + +
+ +
+ + + resetPolicies()} + label="Reset to defaults" + result={resetResult} + className="button danger" + showError={false} + /> +
+ +
+ ); +} + +// A tablist of tab buttons, one for each visibility. +function PolicyPanelsTablist({ selectedVis }: { selectedVis: TextFormInputHook}) { + return ( +
+ + + +
+ ); +} + +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 ( + + ); +} + +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 ( + + ); +} + +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 ( +
+ {legend} + { forAction === "reply" && +
+ + Mentioned accounts can always reply. +
+ } + + + } +