diff options
Diffstat (limited to 'web/source')
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> |