/* 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, }; }