From 94c5cae63108fab3a22d6ff2acd5b9646720fc1e Mon Sep 17 00:00:00 2001 From: mkljczk Date: Mon, 30 Dec 2024 00:19:50 +0100 Subject: [PATCH] pl-fe: Allow to configure per-post interaction policies Signed-off-by: mkljczk --- packages/pl-fe/src/actions/compose.ts | 20 +++++- .../compose/components/compose-form.tsx | 2 + .../components/interaction-policy-button.tsx | 42 ++++++++++++ .../features/interaction-policies/index.tsx | 8 ++- .../src/features/ui/components/modal-root.tsx | 1 + .../compose-interaction-policy-modal.tsx | 65 +++++++++++++++++++ packages/pl-fe/src/reducers/compose.ts | 14 +++- packages/pl-fe/src/stores/modals.ts | 2 + 8 files changed, 150 insertions(+), 4 deletions(-) create mode 100644 packages/pl-fe/src/features/compose/components/interaction-policy-button.tsx create mode 100644 packages/pl-fe/src/features/ui/components/modals/compose-interaction-policy-modal.tsx diff --git a/packages/pl-fe/src/actions/compose.ts b/packages/pl-fe/src/actions/compose.ts index 85918e0ad..022a75692 100644 --- a/packages/pl-fe/src/actions/compose.ts +++ b/packages/pl-fe/src/actions/compose.ts @@ -19,9 +19,10 @@ import { saveSettings } from './settings'; import { createStatus } from './statuses'; import type { EditorState } from 'lexical'; -import type { Account as BaseAccount, CreateStatusParams, CustomEmoji, Group, MediaAttachment, Status as BaseStatus, Tag, Poll, ScheduledStatus } from 'pl-api'; +import type { Account as BaseAccount, CreateStatusParams, CustomEmoji, Group, MediaAttachment, Status as BaseStatus, Tag, Poll, ScheduledStatus, InteractionPolicy } from 'pl-api'; import type { AutoSuggestion } from 'pl-fe/components/autosuggest-input'; import type { Emoji } from 'pl-fe/features/emoji'; +import type { Policy, Rule, Scope } from 'pl-fe/features/interaction-policies'; import type { Account } from 'pl-fe/normalizers/account'; import type { Status } from 'pl-fe/normalizers/status'; import type { AppDispatch, RootState } from 'pl-fe/store'; @@ -92,6 +93,8 @@ const COMPOSE_CHANGE_MEDIA_ORDER = 'COMPOSE_CHANGE_MEDIA_ORDER' as const; 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 getAccount = makeGetAccount(); const messages = defineMessages({ @@ -400,6 +403,7 @@ const submitCompose = (composeId: string, opts: SubmitComposeOpts = {}) => language: compose.language || compose.suggested_language || undefined, to: explicitAddressing && to.length ? to : undefined, local_only: !compose.federated, + interaction_policy: ['public', 'unlisted', 'private'].includes(compose.privacy) && compose.interactionPolicy || undefined, }; if (compose.poll) { @@ -929,6 +933,15 @@ const changeComposeFederated = (composeId: string) => ({ composeId, }); +const changeComposeInteractionPolicyOption = (composeId: string, policy: Policy, rule: Rule, value: Scope[], initial: InteractionPolicy) => ({ + type: COMPOSE_INTERACTION_POLICY_OPTION_CHANGE, + composeId, + policy, + rule, + value, + initial, +}); + type ComposeAction = ComposeSetStatusAction | ReturnType @@ -980,7 +993,8 @@ type ComposeAction = | ReturnType | ReturnType | ReturnType - | ReturnType; + | ReturnType + | ReturnType; export { COMPOSE_CHANGE, @@ -1034,6 +1048,7 @@ export { COMPOSE_ADD_SUGGESTED_QUOTE, COMPOSE_ADD_SUGGESTED_LANGUAGE, COMPOSE_FEDERATED_CHANGE, + COMPOSE_INTERACTION_POLICY_OPTION_CHANGE, setComposeToStatus, replyCompose, cancelReplyCompose, @@ -1080,6 +1095,7 @@ export { addSuggestedQuote, addSuggestedLanguage, changeComposeFederated, + changeComposeInteractionPolicyOption, type ComposeReplyAction, type ComposeSuggestionSelectAction, type ComposeAction, 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 598fc3a9f..ca3e5c471 100644 --- a/packages/pl-fe/src/features/compose/components/compose-form.tsx +++ b/packages/pl-fe/src/features/compose/components/compose-form.tsx @@ -31,6 +31,7 @@ import { $createEmojiNode } from '../editor/nodes/emoji-node'; import { countableText } from '../util/counter'; import ContentTypeButton from './content-type-button'; +import InteractionPolicyButton from './interaction-policy-button'; import LanguageDropdown from './language-dropdown'; import PollButton from './poll-button'; import PollForm from './polls/poll-form'; @@ -186,6 +187,7 @@ const ComposeForm = ({ id, shouldCondense, autoFocus, clickab {features.polls && } {features.scheduledStatuses && } {anyMedia && features.spoilers && } + {features.interactionRequests && } ), [features, id, anyMedia]); diff --git a/packages/pl-fe/src/features/compose/components/interaction-policy-button.tsx b/packages/pl-fe/src/features/compose/components/interaction-policy-button.tsx new file mode 100644 index 000000000..a00a0d070 --- /dev/null +++ b/packages/pl-fe/src/features/compose/components/interaction-policy-button.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + +import { useCompose } from 'pl-fe/hooks/use-compose'; +import { useModalsStore } from 'pl-fe/stores/modals'; + +import ComposeFormButton from './compose-form-button'; + +const messages = defineMessages({ + label: { id: 'compose_form.interaction_policy.label', defaultMessage: 'Manage interaction policy' }, +}); + +interface IInteractionPolicyButton { + composeId: string; +} + +const InteractionPolicyButton: React.FC = ({ composeId }) => { + const intl = useIntl(); + + const { openModal } = useModalsStore(); + + const handleClick = () => { + openModal('COMPOSE_INTERACTION_POLICY', { + composeId, + }); + }; + + const { privacy, interactionPolicy } = useCompose(composeId); + + if (!['public', 'unlisted', 'private'].includes(privacy)) return null; + + return ( + + ); +}; + +export { InteractionPolicyButton as default }; diff --git a/packages/pl-fe/src/features/interaction-policies/index.tsx b/packages/pl-fe/src/features/interaction-policies/index.tsx index 6034c8e4d..0e6447fd8 100644 --- a/packages/pl-fe/src/features/interaction-policies/index.tsx +++ b/packages/pl-fe/src/features/interaction-policies/index.tsx @@ -207,4 +207,10 @@ const InteractionPolicies = () => { ); }; -export { InteractionPolicies as default }; +export { + InteractionPolicies as default, + InteractionPolicyConfig, + type Policy, + type Rule, + type Scope, +}; diff --git a/packages/pl-fe/src/features/ui/components/modal-root.tsx b/packages/pl-fe/src/features/ui/components/modal-root.tsx index e563c34e1..ecf181556 100644 --- a/packages/pl-fe/src/features/ui/components/modal-root.tsx +++ b/packages/pl-fe/src/features/ui/components/modal-root.tsx @@ -15,6 +15,7 @@ const MODAL_COMPONENTS = { COMPARE_HISTORY: lazy(() => import('pl-fe/features/ui/components/modals/compare-history-modal')), COMPONENT: lazy(() => import('pl-fe/features/ui/components/modals/component-modal')), COMPOSE: lazy(() => import('pl-fe/features/ui/components/modals/compose-modal')), + COMPOSE_INTERACTION_POLICY: lazy(() => import('pl-fe/features/ui/components/modals/compose-interaction-policy-modal')), CONFIRM: lazy(() => import('pl-fe/features/ui/components/modals/confirmation-modal')), CREATE_GROUP: lazy(() => import('pl-fe/features/ui/components/modals/manage-group-modal')), CRYPTO_DONATE: lazy(() => import('pl-fe/features/ui/components/modals/crypto-donate-modal')), diff --git a/packages/pl-fe/src/features/ui/components/modals/compose-interaction-policy-modal.tsx b/packages/pl-fe/src/features/ui/components/modals/compose-interaction-policy-modal.tsx new file mode 100644 index 000000000..630b92f59 --- /dev/null +++ b/packages/pl-fe/src/features/ui/components/modals/compose-interaction-policy-modal.tsx @@ -0,0 +1,65 @@ +import React, { useEffect } from 'react'; +import { FormattedMessage } from 'react-intl'; + +import { changeComposeInteractionPolicyOption } from 'pl-fe/actions/compose'; +import Modal from 'pl-fe/components/ui/modal'; +import Stack from 'pl-fe/components/ui/stack'; +import { InteractionPolicyConfig, type Policy, type Rule, type Scope } from 'pl-fe/features/interaction-policies'; +import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch'; +import { useCompose } from 'pl-fe/hooks/use-compose'; +import { useInteractionPolicies } from 'pl-fe/queries/settings/use-interaction-policies'; + +import type { BaseModalProps } from '../modal-root'; + +interface ComposeInteractionPolicyModalProps { + composeId: string; +} + +const ComposeInteractionPolicyModal: React.FC = ({ composeId, onClose }) => { + const dispatch = useAppDispatch(); + const { interactionPolicies: initial } = useInteractionPolicies(); + const compose = useCompose(composeId); + + const canManageInteractionPolicies = compose.privacy === 'public' || compose.privacy === 'unlisted' || compose.privacy === 'private'; + + useEffect(() => { + if (!canManageInteractionPolicies) { + onClose('COMPOSE_INTERACTION_POLICY'); + } + }, []); + + if (!canManageInteractionPolicies) { + + return null; + } + + const interactionPolicy = (compose.interactionPolicy || initial[compose.privacy as 'public']); + + const onClickClose = () => { + onClose('COMPOSE_INTERACTION_POLICY'); + }; + + const onChange = (policy: Policy, rule: Rule, value: Scope[]) => { + dispatch(changeComposeInteractionPolicyOption(composeId, policy, rule, value, interactionPolicy)); + }; + + return ( + } + onClose={onClickClose} + closeIcon={composeId === 'compose-modal' ? require('@tabler/icons/outline/arrow-left.svg') : undefined} + closePosition={composeId === 'compose-modal' ? 'left' : undefined} + > + + + + + ); +}; + +export { ComposeInteractionPolicyModal as default, type ComposeInteractionPolicyModalProps }; diff --git a/packages/pl-fe/src/reducers/compose.ts b/packages/pl-fe/src/reducers/compose.ts index 09afb0935..eaed994b0 100644 --- a/packages/pl-fe/src/reducers/compose.ts +++ b/packages/pl-fe/src/reducers/compose.ts @@ -1,5 +1,4 @@ import { create } from 'mutative'; -import { type CredentialAccount, type Instance, type MediaAttachment, type Tag } from 'pl-api'; import { INSTANCE_FETCH_SUCCESS, type InstanceAction } from 'pl-fe/actions/instance'; import { tagHistory } from 'pl-fe/settings'; @@ -58,6 +57,7 @@ import { COMPOSE_FEDERATED_CHANGE, type ComposeAction, type ComposeSuggestionSelectAction, + COMPOSE_INTERACTION_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'; @@ -65,6 +65,7 @@ import { FE_NAME } from '../actions/settings'; import { TIMELINE_DELETE, type TimelineAction } from '../actions/timelines'; import { unescapeHTML } from '../utils/html'; +import type { InteractionPolicy, CredentialAccount, Instance, MediaAttachment, Tag } from 'pl-api'; import type { Emoji } from 'pl-fe/features/emoji'; import type { Language } from 'pl-fe/features/preferences'; import type { Account } from 'pl-fe/normalizers/account'; @@ -127,6 +128,7 @@ interface Compose { suggested_language: string | null; federated: boolean; approvalRequired: boolean; + interactionPolicy: InteractionPolicy | null; } const newCompose = (params: Partial = {}): Compose => ({ @@ -167,6 +169,7 @@ const newCompose = (params: Partial = {}): Compose => ({ suggested_language: null, federated: true, approvalRequired: false, + interactionPolicy: null, ...params, }); @@ -676,6 +679,15 @@ const compose = (state = initialState, action: ComposeAction | EventsAction | In return updateCompose(state, action.composeId, compose => { compose.federated = !compose.federated; }); + case COMPOSE_INTERACTION_POLICY_OPTION_CHANGE: + return updateCompose(state, action.composeId, compose => { + if (compose.interactionPolicy === null) compose.interactionPolicy = JSON.parse(JSON.stringify(action.initial))!; + + compose.interactionPolicy = create(compose.interactionPolicy || action.initial, (interactionPolicy) => { + interactionPolicy[action.policy][action.rule] = action.value; + 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 INSTANCE_FETCH_SUCCESS: return updateCompose(state, 'default', (compose) => updateDefaultContentType(compose, action.instance)); default: diff --git a/packages/pl-fe/src/stores/modals.ts b/packages/pl-fe/src/stores/modals.ts index bac8bc0cc..c85f103f4 100644 --- a/packages/pl-fe/src/stores/modals.ts +++ b/packages/pl-fe/src/stores/modals.ts @@ -9,6 +9,7 @@ import type { AccountModerationModalProps } from 'pl-fe/features/ui/components/m import type { BoostModalProps } from 'pl-fe/features/ui/components/modals/boost-modal'; import type { CompareHistoryModalProps } from 'pl-fe/features/ui/components/modals/compare-history-modal'; import type { ComponentModalProps } from 'pl-fe/features/ui/components/modals/component-modal'; +import type { ComposeInteractionPolicyModalProps } from 'pl-fe/features/ui/components/modals/compose-interaction-policy-modal'; import type { ComposeModalProps } from 'pl-fe/features/ui/components/modals/compose-modal'; import type { ConfirmationModalProps } from 'pl-fe/features/ui/components/modals/confirmation-modal'; import type { DislikesModalProps } from 'pl-fe/features/ui/components/modals/dislikes-modal'; @@ -45,6 +46,7 @@ type OpenModalProps = | [type: 'COMPARE_HISTORY', props: CompareHistoryModalProps] | [type: 'COMPONENT', props: ComponentModalProps] | [type: 'COMPOSE', props?: ComposeModalProps] + | [type: 'COMPOSE_INTERACTION_POLICY', props?: ComposeInteractionPolicyModalProps] | [type: 'CONFIRM', props: ConfirmationModalProps] | [type: 'CRYPTO_DONATE', props: ICryptoAddress] | [type: 'DISLIKES', props: DislikesModalProps]