From 224a221647c673a1ce93b9aaf73bd42c9573788d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Wed, 18 Feb 2026 15:37:24 +0100 Subject: [PATCH] nicolium: support quote approval policy on mastodon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- packages/pl-fe/src/actions/compose.ts | 5 ++++- .../dropdown-menu/dropdown-menu-item.tsx | 6 ++++++ .../pl-fe/src/components/status-action-bar.tsx | 4 +++- .../compose/components/compose-form.tsx | 17 ++++++++++++----- packages/pl-fe/src/hooks/use-can-interact.ts | 15 +++++++++++++-- packages/pl-fe/src/reducers/compose.ts | 1 + 6 files changed, 39 insertions(+), 9 deletions(-) diff --git a/packages/pl-fe/src/actions/compose.ts b/packages/pl-fe/src/actions/compose.ts index 00170ed5b..7d925a31e 100644 --- a/packages/pl-fe/src/actions/compose.ts +++ b/packages/pl-fe/src/actions/compose.ts @@ -277,10 +277,12 @@ interface ComposeQuoteAction { account: Pick | undefined; explicitAddressing: boolean; conversationScope: boolean; + approvalRequired?: boolean; } const quoteCompose = - (status: ComposeQuoteAction['status']) => (dispatch: AppDispatch, getState: () => RootState) => { + (status: ComposeQuoteAction['status'], approvalRequired?: boolean) => + (dispatch: AppDispatch, getState: () => RootState) => { const state = getState(); const { forceImplicitAddressing } = useSettingsStore.getState().settings; const { createStatusConversationScope, createStatusExplicitAddressing } = @@ -294,6 +296,7 @@ const quoteCompose = account: selectOwnAccount(state), explicitAddressing, conversationScope: createStatusConversationScope, + approvalRequired, }); useModalsStore.getState().actions.openModal('COMPOSE'); }; diff --git a/packages/pl-fe/src/components/dropdown-menu/dropdown-menu-item.tsx b/packages/pl-fe/src/components/dropdown-menu/dropdown-menu-item.tsx index 841e63047..45221eeda 100644 --- a/packages/pl-fe/src/components/dropdown-menu/dropdown-menu-item.tsx +++ b/packages/pl-fe/src/components/dropdown-menu/dropdown-menu-item.tsx @@ -26,6 +26,7 @@ type MenuItem = { items?: Menu; onSelectFile?: (files: FileList) => void; accept?: string; + disabled?: boolean; } & (LinkOptions | { to?: undefined }); interface IDropdownMenuItem { @@ -46,6 +47,7 @@ const DropdownMenuItem = ({ index, item, onClick, autoFocus, onSetTab }: IDropdo event.stopPropagation(); if (!item) return; + if (item.disabled) return; if (item.items?.length) { event.preventDefault(); @@ -74,6 +76,7 @@ const DropdownMenuItem = ({ index, item, onClick, autoFocus, onSetTab }: IDropdo const handleAuxClick: React.EventHandler = (event) => { if (!item) return; + if (item.disabled) return; if (item.onSelectFile) fileElement.current?.click(); if (onClick) onClick(); @@ -93,6 +96,7 @@ const DropdownMenuItem = ({ index, item, onClick, autoFocus, onSetTab }: IDropdo event.stopPropagation(); if (!item) return; + if (item.disabled) return; if (item.onChange) item.onChange(event.target.checked); }; @@ -132,6 +136,7 @@ const DropdownMenuItem = ({ index, item, onClick, autoFocus, onSetTab }: IDropdo 'mx-2 my-1 flex cursor-pointer items-center rounded-md px-2 py-1.5 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-800 focus:bg-gray-100 focus:text-gray-800 focus:outline-none black:hover:bg-gray-900 black:focus:bg-gray-900 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-gray-200 dark:focus:bg-gray-800 dark:focus:text-gray-200', { 'text-danger-600 dark:text-danger-400': item.destructive, + 'cursor-not-allowed opacity-50': item.disabled, }, )} > @@ -185,6 +190,7 @@ const DropdownMenuItem = ({ index, item, onClick, autoFocus, onSetTab }: IDropdo accept={item.accept} onChange={handleSelectFileChange} className='hidden' + disabled={item.disabled} /> )} diff --git a/packages/pl-fe/src/components/status-action-bar.tsx b/packages/pl-fe/src/components/status-action-bar.tsx index 919da3c90..18dd1fcdc 100644 --- a/packages/pl-fe/src/components/status-action-bar.tsx +++ b/packages/pl-fe/src/components/status-action-bar.tsx @@ -421,6 +421,7 @@ const ReblogButton: React.FC = ({ const { boostModal } = useSettings(); const { openModal } = useModalsActions(); const canReblog = useCanInteract(status, 'can_reblog'); + const canQuote = useCanInteract(status, 'can_quote'); const { mutate: reblogStatus } = useReblogStatus(status.id); const { mutate: unreblogStatus } = useUnreblogStatus(status.id); @@ -494,7 +495,7 @@ const ReblogButton: React.FC = ({ const handleQuoteClick: React.EventHandler = (e) => { if (me) { - dispatch(quoteCompose(status)); + dispatch(quoteCompose(status, canQuote.approvalRequired || false)); } else { onOpenUnauthorizedModal('REBLOG'); } @@ -510,6 +511,7 @@ const ReblogButton: React.FC = ({ text: intl.formatMessage(messages.quotePost), action: handleQuoteClick, icon: require('@phosphor-icons/core/regular/quotes.svg'), + disabled: !canQuote.canInteract, }, ]; 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 cf65e5884..882ad805f 100644 --- a/packages/pl-fe/src/features/compose/components/compose-form.tsx +++ b/packages/pl-fe/src/features/compose/components/compose-form.tsx @@ -426,13 +426,20 @@ const ComposeForm = ({ onClick={handleClick} onSubmit={handleSubmit} > - {!!compose.inReplyToId && compose.approvalRequired && ( + {(compose.inReplyToId || compose.quoteId) && compose.approvalRequired && ( + compose.quoteId ? ( + + ) : ( + + ) } /> )} diff --git a/packages/pl-fe/src/hooks/use-can-interact.ts b/packages/pl-fe/src/hooks/use-can-interact.ts index 4ec064734..adf585e94 100644 --- a/packages/pl-fe/src/hooks/use-can-interact.ts +++ b/packages/pl-fe/src/hooks/use-can-interact.ts @@ -6,8 +6,11 @@ import type { MinifiedStatus } from '@/reducers/statuses'; import type { InteractionPolicy, InteractionPolicyEntry } from 'pl-api'; const useCanInteract = ( - status: Pick, - type: keyof InteractionPolicy, + status: Pick< + MinifiedStatus, + 'account_id' | 'id' | 'interaction_policy' | 'mentions' | 'quote_approval' + >, + type: keyof InteractionPolicy | 'can_quote', ): { canInteract: boolean; approvalRequired: boolean | null; @@ -16,6 +19,14 @@ const useCanInteract = ( const me = useAppSelector((state) => state.me); return useMemo(() => { + if (type === 'can_quote') { + const quoteApproval = status.quote_approval; + + return { + canInteract: !quoteApproval || quoteApproval.current_user !== 'denied', + approvalRequired: quoteApproval?.current_user === 'manual', + }; + } const interactionPolicy = status.interaction_policy; if (me === status.account_id || interactionPolicy[type].always.includes('me')) diff --git a/packages/pl-fe/src/reducers/compose.ts b/packages/pl-fe/src/reducers/compose.ts index b3bf59c31..6aca81ec6 100644 --- a/packages/pl-fe/src/reducers/compose.ts +++ b/packages/pl-fe/src/reducers/compose.ts @@ -509,6 +509,7 @@ const compose = ( compose.caretPosition = null; compose.contentType = defaultCompose.contentType; compose.spoilerText = ''; + compose.approvalRequired = action.approvalRequired ?? false; if (action.status.visibility === 'group') { compose.groupId = action.status.group_id;