pl-fe: allow setting quote policy on mastodon

Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
nicole mikołajczyk
2026-01-04 18:32:53 +01:00
parent af6d55020d
commit 43ce3fa749
9 changed files with 117 additions and 16 deletions

View File

@ -78,12 +78,14 @@ interface UpdateCredentialsParams {
value: string;
}>;
source?: {
/** String. Default post privacy for authored statuses. Can be public, unlisted, or private. */
/** String. Default post privacy for authored statuses. Can be `public`, `unlisted`, or `private`. */
privacy?: string;
/** Boolean. Whether to mark authored statuses as sensitive by default. */
sensitive?: boolean;
/** String. Default language to use for authored statuses (ISO 6391) */
language?: string;
/** String (Enumerable, oneOf `public` `followers` `nobody`). Default quote policy for new posts. */
quote_policy?: 'public' | 'followers' | 'nobody';
};
/** if true, html tags are stripped from all statuses requested from the API */

View File

@ -94,6 +94,7 @@ const COMPOSE_ADD_SUGGESTED_QUOTE = 'COMPOSE_ADD_SUGGESTED_QUOTE' as const;
const COMPOSE_ADD_SUGGESTED_LANGUAGE = 'COMPOSE_ADD_SUGGESTED_LANGUAGE' as const;
const COMPOSE_INTERACTION_POLICY_OPTION_CHANGE = 'COMPOSE_INTERACTION_POLICY_OPTION_CHANGE' as const;
const COMPOSE_QUOTE_POLICY_OPTION_CHANGE = 'COMPOSE_QUOTE_POLICY_OPTION_CHANGE' as const;
const COMPOSE_CLEAR_LINK_SUGGESTION_CREATE = 'COMPOSE_CLEAR_LINK_SUGGESTION_CREATE' as const;
const COMPOSE_CLEAR_LINK_SUGGESTION_IGNORE = 'COMPOSE_CLEAR_LINK_SUGGESTION_IGNORE' as const;
@ -417,6 +418,7 @@ const submitCompose = (composeId: string, opts: SubmitComposeOpts = {}, preview
to: explicitAddressing && to.length ? to : undefined,
local_only: compose.localOnly,
interaction_policy: ['public', 'unlisted', 'private'].includes(compose.visibility) && compose.interactionPolicy || undefined,
quote_approval_policy: compose.quoteApprovalPolicy || undefined,
preview,
};
@ -952,6 +954,12 @@ const changeComposeInteractionPolicyOption = (composeId: string, policy: Policy,
initial,
});
const changeComposeQuotePolicyOption = (composeId: string, value: CreateStatusParams['quote_approval_policy']) => ({
type: COMPOSE_QUOTE_POLICY_OPTION_CHANGE,
composeId,
value,
});
const suggestClearLink = (composeId: string, suggestion: ClearLinkSuggestion | null) => ({
type: COMPOSE_CLEAR_LINK_SUGGESTION_CREATE,
composeId,
@ -1035,6 +1043,7 @@ type ComposeAction =
| ReturnType<typeof addSuggestedLanguage>
| ReturnType<typeof changeComposeFederated>
| ReturnType<typeof changeComposeInteractionPolicyOption>
| ReturnType<typeof changeComposeQuotePolicyOption>
| ReturnType<typeof suggestClearLink>
| ReturnType<typeof ignoreClearLinkSuggestion>
| ReturnType<typeof suggestHashtagCasing>
@ -1095,6 +1104,7 @@ export {
COMPOSE_ADD_SUGGESTED_LANGUAGE,
COMPOSE_FEDERATED_CHANGE,
COMPOSE_INTERACTION_POLICY_OPTION_CHANGE,
COMPOSE_QUOTE_POLICY_OPTION_CHANGE,
COMPOSE_CLEAR_LINK_SUGGESTION_CREATE,
COMPOSE_CLEAR_LINK_SUGGESTION_IGNORE,
COMPOSE_HASHTAG_CASING_SUGGESTION_SET,
@ -1146,6 +1156,7 @@ export {
addSuggestedLanguage,
changeComposeFederated,
changeComposeInteractionPolicyOption,
changeComposeQuotePolicyOption,
suggestClearLink,
ignoreClearLinkSuggestion,
cancelPreviewCompose,

View File

@ -287,7 +287,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
{features.polls && <PollButton composeId={id} />}
{features.scheduledStatuses && <ScheduleButton composeId={id} />}
{anyMedia && features.spoilers && <SensitiveMediaButton composeId={id} />}
{features.interactionRequests && <InteractionPolicyButton composeId={id} />}
{(features.interactionRequests || features.quoteApprovalPolicies) && <InteractionPolicyButton composeId={id} />}
</div>
), [features, id, anyMedia]);

View File

@ -954,7 +954,7 @@ export const settingsInteractionPoliciesRoute = createRoute({
path: '/settings/interaction_policies',
component: InteractionPolicies,
beforeLoad: requireAuthMiddleware(({ context: { features } }) => {
if (!features.interactionRequests) throw notFound();
if (!features.interactionRequests && !features.quoteApprovalPolicies) throw notFound();
}),
});

View File

@ -1061,6 +1061,7 @@
"interaction_policies.entry.followers": "Followers",
"interaction_policies.entry.following": "People I follow",
"interaction_policies.entry.mentioned": "Mentioned",
"interaction_policies.entry.nobody": "Nobody",
"interaction_policies.entry.public": "Everyone",
"interaction_policies.fail": "Failed to update interaction policies",
"interaction_policies.mentioned_warning": "Mentioned users can always reply.",
@ -1075,9 +1076,11 @@
"interaction_policies.title.private.can_reblog": "Who can repost your followers-only post?",
"interaction_policies.title.private.can_reply": "Who can reply to your followers-only post?",
"interaction_policies.title.public.can_favourite": "Who can like your public posts?",
"interaction_policies.title.public.can_quote": "Who can quote your posts?",
"interaction_policies.title.public.can_reblog": "Who can repost your public posts?",
"interaction_policies.title.public.can_reply": "Who can reply to your public posts?",
"interaction_policies.title.single_post.can_favourite": "Who can like this post?",
"interaction_policies.title.single_post.can_quote": "Who can quote this post?",
"interaction_policies.title.single_post.can_reblog": "Who can repost this post?",
"interaction_policies.title.single_post.can_reply": "Who can reply to this post?",
"interaction_policies.title.unlisted.can_favourite": "Who can like your unlisted posts?",

View File

@ -1,16 +1,18 @@
import { Link } from '@tanstack/react-router';
import React, { useEffect } from 'react';
import React, { useEffect, useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { changeComposeInteractionPolicyOption } from 'pl-fe/actions/compose';
import { changeComposeInteractionPolicyOption, changeComposeQuotePolicyOption } from 'pl-fe/actions/compose';
import Modal from 'pl-fe/components/ui/modal';
import Stack from 'pl-fe/components/ui/stack';
import Warning from 'pl-fe/features/compose/components/warning';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import { useClient } from 'pl-fe/hooks/use-client';
import { useCompose } from 'pl-fe/hooks/use-compose';
import { InteractionPolicyConfig, type Policy, type Rule, type Scope } from 'pl-fe/pages/settings/interaction-policies';
import { useInteractionPolicies } from 'pl-fe/queries/settings/use-interaction-policies';
import type { CreateStatusParams } from 'pl-api';
import type { BaseModalProps } from 'pl-fe/features/ui/components/modal-root';
const MANAGABLE_VISIBILITIES = ['public', 'unlisted', 'private'];
@ -20,7 +22,9 @@ interface ComposeInteractionPolicyModalProps {
}
const ComposeInteractionPolicyModal: React.FC<BaseModalProps & ComposeInteractionPolicyModalProps> = ({ composeId, onClose }) => {
const client = useClient();
const dispatch = useAppDispatch();
const [initialQuotePolicy, setInitialQuotePolicy] = useState<CreateStatusParams['quote_approval_policy']>(undefined);
const { interactionPolicies: initial } = useInteractionPolicies();
const compose = useCompose(composeId);
@ -30,6 +34,10 @@ const ComposeInteractionPolicyModal: React.FC<BaseModalProps & ComposeInteractio
if (!canManageInteractionPolicies) {
onClose('COMPOSE_INTERACTION_POLICY');
}
client.settings.verifyCredentials().then((credentialAccount) => {
setInitialQuotePolicy(credentialAccount.source?.quote_policy || 'public');
}).catch(() => {});
}, []);
if (!canManageInteractionPolicies) {
@ -47,6 +55,10 @@ const ComposeInteractionPolicyModal: React.FC<BaseModalProps & ComposeInteractio
dispatch(changeComposeInteractionPolicyOption(composeId, policy, rule, value, interactionPolicy));
};
const onQuotePolicyChange = (value: CreateStatusParams['quote_approval_policy']) => {
dispatch(changeComposeQuotePolicyOption(composeId, value));
};
return (
<Modal
title={<FormattedMessage id='navigation_bar.interaction_policy' defaultMessage='Status interaction rules' />}
@ -72,8 +84,10 @@ const ComposeInteractionPolicyModal: React.FC<BaseModalProps & ComposeInteractio
/>
<InteractionPolicyConfig
interactionPolicy={interactionPolicy}
visibility={compose.visibility as 'public'}
onChange={onChange}
quotePolicy={initialQuotePolicy}
onQuotePolicyChange={onQuotePolicyChange}
visibility={compose.visibility as 'public'}
singlePost
/>
</Stack>

View File

@ -2,6 +2,7 @@ import { create } from 'mutative';
import React, { useEffect, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { patchMe } from 'pl-fe/actions/me';
import List, { ListItem } from 'pl-fe/components/list';
import Button from 'pl-fe/components/ui/button';
import Column from 'pl-fe/components/ui/column';
@ -11,16 +12,22 @@ import { InlineMultiselect } from 'pl-fe/components/ui/inline-multiselect';
import Tabs from 'pl-fe/components/ui/tabs';
import Text from 'pl-fe/components/ui/text';
import Warning from 'pl-fe/features/compose/components/warning';
import { SelectDropdown } from 'pl-fe/features/forms';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import { useClient } from 'pl-fe/hooks/use-client';
import { useFeatures } from 'pl-fe/hooks/use-features';
import { useInteractionPolicies } from 'pl-fe/queries/settings/use-interaction-policies';
import toast from 'pl-fe/toast';
import type { InteractionPolicy } from 'pl-api';
import type { CreateStatusParams, InteractionPolicy } from 'pl-api';
type Visibility = 'public' | 'unlisted' | 'private';
type Policy = 'can_favourite' | 'can_reblog' | 'can_reply';
type Rule = 'always' | 'with_approval';
type Scope = 'followers' | 'following' | 'mentioned' | 'public';
type QuoteApprovalPolicy = CreateStatusParams['quote_approval_policy'];
const policies: Array<Policy> = ['can_favourite', 'can_reply', 'can_reblog'];
const messages = defineMessages({
@ -40,6 +47,7 @@ const scopeMessages = defineMessages({
following: { id: 'interaction_policies.entry.following', defaultMessage: 'People I follow' },
mentioned: { id: 'interaction_policies.entry.mentioned', defaultMessage: 'Mentioned' },
public: { id: 'interaction_policies.entry.public', defaultMessage: 'Everyone' },
nobody: { id: 'interaction_policies.entry.nobody', defaultMessage: 'Nobody' },
});
const titleMessages = {
@ -47,21 +55,25 @@ const titleMessages = {
can_favourite: { id: 'interaction_policies.title.public.can_favourite', defaultMessage: 'Who can like your public posts?' },
can_reply: { id: 'interaction_policies.title.public.can_reply', defaultMessage: 'Who can reply to your public posts?' },
can_reblog: { id: 'interaction_policies.title.public.can_reblog', defaultMessage: 'Who can repost your public posts?' },
can_quote: { id: 'interaction_policies.title.public.can_quote', defaultMessage: 'Who can quote your posts?' },
}),
unlisted: defineMessages({
can_favourite: { id: 'interaction_policies.title.unlisted.can_favourite', defaultMessage: 'Who can like your unlisted posts?' },
can_reply: { id: 'interaction_policies.title.unlisted.can_reply', defaultMessage: 'Who can reply to your unlisted posts?' },
can_reblog: { id: 'interaction_policies.title.unlisted.can_reblog', defaultMessage: 'Who can repost your unlisted posts?' },
can_quote: { id: 'interaction_policies.title.public.can_quote', defaultMessage: 'Who can quote your posts?' },
}),
private: defineMessages({
can_favourite: { id: 'interaction_policies.title.private.can_favourite', defaultMessage: 'Who can like your followers-only post?' },
can_reply: { id: 'interaction_policies.title.private.can_reply', defaultMessage: 'Who can reply to your followers-only post?' },
can_reblog: { id: 'interaction_policies.title.private.can_reblog', defaultMessage: 'Who can repost your followers-only post?' },
can_quote: { id: 'interaction_policies.title.public.can_quote', defaultMessage: 'Who can quote your posts?' },
}),
single_post: defineMessages({
can_favourite: { id: 'interaction_policies.title.single_post.can_favourite', defaultMessage: 'Who can like this post?' },
can_reply: { id: 'interaction_policies.title.single_post.can_reply', defaultMessage: 'Who can reply to this post?' },
can_reblog: { id: 'interaction_policies.title.single_post.can_reblog', defaultMessage: 'Who can repost this post?' },
can_quote: { id: 'interaction_policies.title.single_post.can_quote', defaultMessage: 'Who can quote this post?' },
}),
};
@ -85,13 +97,16 @@ const options: Record<Visibility, Record<Policy, Array<Scope>>> = {
interface IInteractionPolicyConfig {
interactionPolicy: InteractionPolicy;
visibility: Visibility;
onChange: (policy: Policy, rule: Rule, value: Scope[]) => void;
quotePolicy?: QuoteApprovalPolicy;
onQuotePolicyChange?: (value: QuoteApprovalPolicy) => void;
visibility: Visibility;
singlePost?: boolean;
disabled?: boolean;
}
const InteractionPolicyConfig: React.FC<IInteractionPolicyConfig> = ({ interactionPolicy, visibility, onChange, singlePost, disabled }) => {
const InteractionPolicyConfig: React.FC<IInteractionPolicyConfig> = ({ interactionPolicy, quotePolicy, visibility, onChange, onQuotePolicyChange, singlePost, disabled }) => {
const features = useFeatures();
const intl = useIntl();
const getItems = (policy: Policy) => Object.fromEntries(options[visibility][policy].map(scope => [scope, intl.formatMessage(scopeMessages[scope])])) as Record<Scope, string>;
@ -102,7 +117,7 @@ const InteractionPolicyConfig: React.FC<IInteractionPolicyConfig> = ({ interacti
return (
<>
{policies.map((policy) => {
{features.interactionRequests && policies.map((policy) => {
const items = getItems(policy);
if (!Object.keys(items).length) return null;
@ -138,11 +153,35 @@ const InteractionPolicyConfig: React.FC<IInteractionPolicyConfig> = ({ interacti
</React.Fragment>
);
})}
{features.quoteApprovalPolicies && visibility !== 'private' && (
<>
<Text size='lg' weight='bold'>
{intl.formatMessage(titleMessages[singlePost ? 'single_post' : visibility].can_quote)}
</Text>
<SelectDropdown
key={quotePolicy === undefined ? '1' : '0'}
items={{
public: intl.formatMessage(scopeMessages.public),
followers: intl.formatMessage(scopeMessages.followers),
nobody: intl.formatMessage(scopeMessages.nobody),
}}
defaultValue={quotePolicy}
onChange={(event) => onQuotePolicyChange?.(event.target.value as QuoteApprovalPolicy)}
/>
</>
)}
</>
);
};
const InteractionPoliciesPage = () => {
const client = useClient();
const dispatch = useAppDispatch();
const features = useFeatures();
const [quotePolicy, setQuotePolicy] = useState<QuoteApprovalPolicy>('public');
const { interactionPolicies: initial, updateInteractionPolicies, isUpdating } = useInteractionPolicies();
const intl = useIntl();
const [interactionPolicies, setInteractionPolicies] = useState(initial);
@ -152,6 +191,13 @@ const InteractionPoliciesPage = () => {
setInteractionPolicies(initial);
}, [initial]);
useEffect(() => {
client.settings.verifyCredentials().then((credentialAccount) => {
setQuotePolicy(credentialAccount.source?.quote_policy || 'public');
}).catch(() => {
});
}, []);
const handleChange = (visibility: Visibility, policy: Policy, rule: Rule, value: Array<Scope>) => {
setInteractionPolicies((policies) => create(policies, (draft) => {
draft[visibility][policy][rule] = value;
@ -160,9 +206,25 @@ const InteractionPoliciesPage = () => {
};
const handleSubmit = () => {
updateInteractionPolicies(interactionPolicies, {
onSuccess: () => toast.success(messages.success),
onError: () => toast.success(messages.fail),
const promises = [];
if (features.interactionRequests) {
promises.push(new Promise<void>((resolve, reject) => {
updateInteractionPolicies(interactionPolicies, {
onSuccess: () => resolve(),
onError: () => reject(),
});
}));
}
if (features.quoteApprovalPolicies) {
promises.push(dispatch(patchMe({ source: { quote_policy: quotePolicy } })));
}
Promise.all(promises).then(() => {
toast.success(intl.formatMessage(messages.success));
}).catch(() => {
toast.error(intl.formatMessage(messages.fail));
});
};
@ -191,8 +253,10 @@ const InteractionPoliciesPage = () => {
<InteractionPolicyConfig
interactionPolicy={interactionPolicies[visibility]}
visibility={visibility}
onChange={(...props) => handleChange(visibility, ...props)}
quotePolicy={quotePolicy}
onQuotePolicyChange={setQuotePolicy}
visibility={visibility}
disabled={isUpdating}
/>

View File

@ -79,7 +79,7 @@ const SettingsPage = () => {
<ListItem label={intl.formatMessage(messages.blocks)} to='/blocks' />
{(features.filters || features.filtersV2) && <ListItem label={intl.formatMessage(messages.filters)} to='/filters' />}
{features.federating && <ListItem label={intl.formatMessage(messages.domainBlocks)} to='/domain_blocks' />}
{features.interactionRequests && <ListItem label={intl.formatMessage(messages.interactionPolicies)} to='/settings/interaction_policies' />}
{(features.interactionRequests || features.quoteApprovalPolicies) && <ListItem label={intl.formatMessage(messages.interactionPolicies)} to='/settings/interaction_policies' />}
</List>
</CardBody>

View File

@ -63,6 +63,7 @@ import {
type ComposeAction,
type ComposeSuggestionSelectAction,
COMPOSE_REDACTING_OVERWRITE_CHANGE,
COMPOSE_QUOTE_POLICY_OPTION_CHANGE,
} from '../actions/compose';
import { EVENT_COMPOSE_CANCEL, EVENT_FORM_SET, type EventsAction } from '../actions/events';
import { ME_FETCH_SUCCESS, ME_PATCH_SUCCESS, type MeAction } from '../actions/me';
@ -70,7 +71,7 @@ import { FE_NAME } from '../actions/settings';
import { TIMELINE_DELETE, type TimelineAction } from '../actions/timelines';
import { unescapeHTML } from '../utils/html';
import type { Account, CredentialAccount, Instance, InteractionPolicy, MediaAttachment, Status as BaseStatus, Tag } from 'pl-api';
import type { Account, CredentialAccount, Instance, InteractionPolicy, MediaAttachment, Status as BaseStatus, Tag, CreateStatusParams } from 'pl-api';
import type { Emoji } from 'pl-fe/features/emoji';
import type { Language } from 'pl-fe/features/preferences';
import type { Status } from 'pl-fe/normalizers/status';
@ -116,6 +117,7 @@ interface Compose {
// Post settings
contentType: string;
interactionPolicy: InteractionPolicy | null;
quoteApprovalPolicy: CreateStatusParams['quote_approval_policy'] | null;
language: Language | string | null;
localOnly: boolean;
scheduledAt: Date | null;
@ -174,6 +176,7 @@ const newCompose = (params: Partial<Compose> = {}): Compose => ({
contentType: 'text/plain',
interactionPolicy: null,
quoteApprovalPolicy: null,
language: null,
localOnly: false,
scheduledAt: null,
@ -712,6 +715,10 @@ const compose = (state = initialState, action: ComposeAction | EventsAction | In
interactionPolicy[action.policy][action.rule === 'always' ? 'with_approval' : 'always'] = interactionPolicy[action.policy][action.rule === 'always' ? 'with_approval' : 'always'].filter(rule => !action.value.includes(rule as any));
});
});
case COMPOSE_QUOTE_POLICY_OPTION_CHANGE:
return updateCompose(state, action.composeId, compose => {
compose.quoteApprovalPolicy = action.value;
});
case INSTANCE_FETCH_SUCCESS:
return updateCompose(state, 'default', (compose) => updateDefaultContentType(compose, action.instance));
case COMPOSE_CLEAR_LINK_SUGGESTION_CREATE: