diff options
author | 2024-07-17 16:46:52 +0200 | |
---|---|---|
committer | 2024-07-17 16:46:52 +0200 | |
commit | 0aadc2db2a42fc99538fbbb096b84b209b9ccd68 (patch) | |
tree | 38c58d163004d43da80d33477a6e9f22547bdb15 /web/source/settings/views | |
parent | give read-only access to /dev for ffmpeg to access /dev/urandom (#3109) (diff) | |
download | gotosocial-0aadc2db2a42fc99538fbbb096b84b209b9ccd68.tar.xz |
[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
Diffstat (limited to 'web/source/settings/views')
-rw-r--r-- | web/source/settings/views/user/emailpassword.tsx (renamed from web/source/settings/views/user/settings.tsx) | 83 | ||||
-rw-r--r-- | web/source/settings/views/user/menu.tsx | 14 | ||||
-rw-r--r-- | web/source/settings/views/user/posts/basic-settings/index.tsx | 88 | ||||
-rw-r--r-- | web/source/settings/views/user/posts/index.tsx | 51 | ||||
-rw-r--r-- | web/source/settings/views/user/posts/interaction-policy-settings/basic.tsx | 180 | ||||
-rw-r--r-- | web/source/settings/views/user/posts/interaction-policy-settings/index.tsx | 553 | ||||
-rw-r--r-- | web/source/settings/views/user/posts/interaction-policy-settings/something-else.tsx | 124 | ||||
-rw-r--r-- | web/source/settings/views/user/posts/interaction-policy-settings/types.ts | 35 | ||||
-rw-r--r-- | web/source/settings/views/user/router.tsx | 9 |
9 files changed, 1054 insertions, 83 deletions
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> |