diff --git a/packages/pl-api/lib/params/settings.ts b/packages/pl-api/lib/params/settings.ts index 6ead58aff..b0733c8e8 100644 --- a/packages/pl-api/lib/params/settings.ts +++ b/packages/pl-api/lib/params/settings.ts @@ -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 */ diff --git a/packages/pl-fe/src/actions/compose.ts b/packages/pl-fe/src/actions/compose.ts index 31f488243..e29e4a1b6 100644 --- a/packages/pl-fe/src/actions/compose.ts +++ b/packages/pl-fe/src/actions/compose.ts @@ -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 | ReturnType | ReturnType + | ReturnType | ReturnType | ReturnType | ReturnType @@ -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, diff --git a/packages/pl-fe/src/features/compose/components/compose-form.tsx b/packages/pl-fe/src/features/compose/components/compose-form.tsx index 166cec18d..331e992fe 100644 --- a/packages/pl-fe/src/features/compose/components/compose-form.tsx +++ b/packages/pl-fe/src/features/compose/components/compose-form.tsx @@ -287,7 +287,7 @@ const ComposeForm = ({ id, shouldCondense, autoFocus, clickab {features.polls && } {features.scheduledStatuses && } {anyMedia && features.spoilers && } - {features.interactionRequests && } + {(features.interactionRequests || features.quoteApprovalPolicies) && } ), [features, id, anyMedia]); diff --git a/packages/pl-fe/src/features/ui/router/index.tsx b/packages/pl-fe/src/features/ui/router/index.tsx index 4ea75d264..296f1e92b 100644 --- a/packages/pl-fe/src/features/ui/router/index.tsx +++ b/packages/pl-fe/src/features/ui/router/index.tsx @@ -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(); }), }); diff --git a/packages/pl-fe/src/locales/en.json b/packages/pl-fe/src/locales/en.json index 403170b94..3b96893d0 100644 --- a/packages/pl-fe/src/locales/en.json +++ b/packages/pl-fe/src/locales/en.json @@ -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?", diff --git a/packages/pl-fe/src/modals/compose-interaction-policy-modal.tsx b/packages/pl-fe/src/modals/compose-interaction-policy-modal.tsx index 3d07d5648..608b0410c 100644 --- a/packages/pl-fe/src/modals/compose-interaction-policy-modal.tsx +++ b/packages/pl-fe/src/modals/compose-interaction-policy-modal.tsx @@ -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 = ({ composeId, onClose }) => { + const client = useClient(); const dispatch = useAppDispatch(); + const [initialQuotePolicy, setInitialQuotePolicy] = useState(undefined); const { interactionPolicies: initial } = useInteractionPolicies(); const compose = useCompose(composeId); @@ -30,6 +34,10 @@ const ComposeInteractionPolicyModal: React.FC { + setInitialQuotePolicy(credentialAccount.source?.quote_policy || 'public'); + }).catch(() => {}); }, []); if (!canManageInteractionPolicies) { @@ -47,6 +55,10 @@ const ComposeInteractionPolicyModal: React.FC { + dispatch(changeComposeQuotePolicyOption(composeId, value)); + }; + return ( } @@ -72,8 +84,10 @@ const ComposeInteractionPolicyModal: React.FC diff --git a/packages/pl-fe/src/pages/settings/interaction-policies.tsx b/packages/pl-fe/src/pages/settings/interaction-policies.tsx index 2815b9ac8..ea843bd59 100644 --- a/packages/pl-fe/src/pages/settings/interaction-policies.tsx +++ b/packages/pl-fe/src/pages/settings/interaction-policies.tsx @@ -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 = ['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>> = { 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 = ({ interactionPolicy, visibility, onChange, singlePost, disabled }) => { +const InteractionPolicyConfig: React.FC = ({ 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; @@ -102,7 +117,7 @@ const InteractionPolicyConfig: React.FC = ({ 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 = ({ interacti ); })} + {features.quoteApprovalPolicies && visibility !== 'private' && ( + <> + + {intl.formatMessage(titleMessages[singlePost ? 'single_post' : visibility].can_quote)} + + + onQuotePolicyChange?.(event.target.value as QuoteApprovalPolicy)} + /> + + )} ); }; const InteractionPoliciesPage = () => { + const client = useClient(); + const dispatch = useAppDispatch(); + const features = useFeatures(); + + const [quotePolicy, setQuotePolicy] = useState('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) => { 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((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 = () => { handleChange(visibility, ...props)} + quotePolicy={quotePolicy} + onQuotePolicyChange={setQuotePolicy} + visibility={visibility} disabled={isUpdating} /> diff --git a/packages/pl-fe/src/pages/settings/settings.tsx b/packages/pl-fe/src/pages/settings/settings.tsx index 24cad125c..9db15c762 100644 --- a/packages/pl-fe/src/pages/settings/settings.tsx +++ b/packages/pl-fe/src/pages/settings/settings.tsx @@ -79,7 +79,7 @@ const SettingsPage = () => { {(features.filters || features.filtersV2) && } {features.federating && } - {features.interactionRequests && } + {(features.interactionRequests || features.quoteApprovalPolicies) && } diff --git a/packages/pl-fe/src/reducers/compose.ts b/packages/pl-fe/src/reducers/compose.ts index 92788a9fc..b239f5e8c 100644 --- a/packages/pl-fe/src/reducers/compose.ts +++ b/packages/pl-fe/src/reducers/compose.ts @@ -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 => ({ 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: