[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:
tobi
2024-07-17 16:46:52 +02:00
committed by GitHub
parent 401098191b
commit 0aadc2db2a
36 changed files with 3178 additions and 316 deletions

View File

@ -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}

View File

@ -141,6 +141,7 @@ export const gtsApi = createApi({
"InstanceRules",
"HTTPHeaderAllows",
"HTTPHeaderBlocks",
"DefaultInteractionPolicies",
],
endpoints: (build) => ({
instanceV1: build.query<InstanceV1, void>({

View File

@ -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;

View File

@ -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 {

View File

@ -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,
};

View File

@ -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;

View File

@ -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>
);
}
}

View File

@ -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"

View File

@ -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>
);
}

View 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 />
</>
);
}

View File

@ -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),
};
}

View File

@ -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,
};
}

View File

@ -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"),
},
};
}

View File

@ -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";

View File

@ -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>