mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2024-12-24 19:22:12 +00:00
0aadc2db2a
* [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
554 lines
14 KiB
TypeScript
554 lines
14 KiB
TypeScript
/*
|
|
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,
|
|
};
|
|
}
|