mirror of
https://github.com/superseriousbusiness/gotosocial
synced 2025-06-05 21:59:39 +02:00
[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
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
@@ -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"
|
||||
|
@@ -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>
|
||||
);
|
||||
}
|
51
web/source/settings/views/user/posts/index.tsx
Normal file
51
web/source/settings/views/user/posts/index.tsx
Normal file
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
@@ -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),
|
||||
};
|
||||
}
|
@@ -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,
|
||||
};
|
||||
}
|
@@ -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"),
|
||||
},
|
||||
};
|
||||
}
|
@@ -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";
|
@@ -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>
|
||||
|
Reference in New Issue
Block a user