diff --git a/src/api/hooks/admin/index.ts b/src/api/hooks/admin/index.ts index 3a4c8050b..c34853340 100644 --- a/src/api/hooks/admin/index.ts +++ b/src/api/hooks/admin/index.ts @@ -5,6 +5,7 @@ export { useDeleteRelay } from './useDeleteRelay'; export { useDomains } from './useDomains'; export { useModerationLog } from './useModerationLog'; export { useRelays } from './useRelays'; +export { useRules } from './useRules'; export { useSuggest } from './useSuggest'; export { useUpdateDomain } from './useUpdateDomain'; export { useVerify } from './useVerify'; \ No newline at end of file diff --git a/src/api/hooks/admin/useRules.ts b/src/api/hooks/admin/useRules.ts new file mode 100644 index 000000000..a99c01935 --- /dev/null +++ b/src/api/hooks/admin/useRules.ts @@ -0,0 +1,84 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import { AxiosResponse } from 'axios'; + +import { useApi } from 'soapbox/hooks'; +import { queryClient } from 'soapbox/queries/client'; +import { adminRuleSchema, type AdminRule } from 'soapbox/schemas'; + +interface CreateRuleParams { + priority?: number; + text: string; + hint?: string; +} + +interface UpdateRuleParams { + id: string; + priority?: number; + text?: string; + hint?: string; +} + +const useRules = () => { + const api = useApi(); + + const getRules = async () => { + const { data } = await api.get('/api/v1/pleroma/admin/rules'); + + const normalizedData = data.map((rule) => adminRuleSchema.parse(rule)); + return normalizedData; + }; + + const result = useQuery>({ + queryKey: ['admin', 'rules'], + queryFn: getRules, + placeholderData: [], + }); + + const { + mutate: createRule, + isPending: isCreating, + } = useMutation({ + mutationFn: (params: CreateRuleParams) => api.post('/api/v1/pleroma/admin/rules', params), + retry: false, + onSuccess: ({ data }: AxiosResponse) => + queryClient.setQueryData(['admin', 'rules'], (prevResult: ReadonlyArray) => + [...prevResult, adminRuleSchema.parse(data)], + ), + }); + + const { + mutate: updateRule, + isPending: isUpdating, + } = useMutation({ + mutationFn: ({ id, ...params }: UpdateRuleParams) => api.patch(`/api/v1/pleroma/admin/rules/${id}`, params), + retry: false, + onSuccess: ({ data }: AxiosResponse) => + queryClient.setQueryData(['admin', 'rules'], (prevResult: ReadonlyArray) => + prevResult.map((rule) => rule.id === data.id ? adminRuleSchema.parse(data) : rule), + ), + }); + + const { + mutate: deleteRule, + isPending: isDeleting, + } = useMutation({ + mutationFn: (id: string) => api.delete(`/api/v1/pleroma/admin/rules/${id}`), + retry: false, + onSuccess: (_, id) => + queryClient.setQueryData(['admin', 'rules'], (prevResult: ReadonlyArray) => + prevResult.filter(({ id: ruleId }) => ruleId !== id), + ), + }); + + return { + ...result, + createRule, + isCreating, + updateRule, + isUpdating, + deleteRule, + isDeleting, + }; +}; + +export { useRules }; diff --git a/src/entity-store/entities.ts b/src/entity-store/entities.ts index ea7433104..13cdf4eaa 100644 --- a/src/entity-store/entities.ts +++ b/src/entity-store/entities.ts @@ -12,7 +12,8 @@ enum Entities { PATRON_USERS = 'PatronUsers', RELATIONSHIPS = 'Relationships', RELAYS = 'Relays', - STATUSES = 'Statuses' + RULES = 'Rules', + STATUSES = 'Statuses', } interface EntityTypes { @@ -26,6 +27,7 @@ interface EntityTypes { [Entities.PATRON_USERS]: Schemas.PatronUser; [Entities.RELATIONSHIPS]: Schemas.Relationship; [Entities.RELAYS]: Schemas.Relay; + [Entities.RULES]: Schemas.AdminRule; [Entities.STATUSES]: Schemas.Status; } diff --git a/src/features/admin/rules.tsx b/src/features/admin/rules.tsx new file mode 100644 index 000000000..e4b9754e5 --- /dev/null +++ b/src/features/admin/rules.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; + +import { openModal } from 'soapbox/actions/modals'; +import { useRules } from 'soapbox/api/hooks/admin'; +import ScrollableList from 'soapbox/components/scrollable-list'; +import { Button, Column, HStack, Stack, Text } from 'soapbox/components/ui'; +import { useAppDispatch } from 'soapbox/hooks'; +import { AdminRule } from 'soapbox/schemas'; +import toast from 'soapbox/toast'; + +const messages = defineMessages({ + heading: { id: 'column.admin.rules', defaultMessage: 'Instance rules' }, + deleteConfirm: { id: 'confirmations.admin.delete_rule.confirm', defaultMessage: 'Delete' }, + deleteHeading: { id: 'confirmations.admin.delete_rule.heading', defaultMessage: 'Delete rule' }, + deleteMessage: { id: 'confirmations.admin.delete_rule.message', defaultMessage: 'Are you sure you want to delete the rule?' }, + ruleDeleteSuccess: { id: 'admin.edit_rule.deleted', defaultMessage: 'Rule deleted' }, +}); + +interface IRule { + rule: AdminRule; +} + +const Rule: React.FC = ({ rule }) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + const { deleteRule } = useRules(); + + const handleEditRule = (rule: AdminRule) => () => { + dispatch(openModal('EDIT_RULE', { rule })); + }; + + const handleDeleteRule = (id: string) => () => { + dispatch(openModal('CONFIRM', { + heading: intl.formatMessage(messages.deleteHeading), + message: intl.formatMessage(messages.deleteMessage), + confirm: intl.formatMessage(messages.deleteConfirm), + onConfirm: () => deleteRule(id, { + onSuccess: () => toast.success(messages.ruleDeleteSuccess), + }), + })); + }; + + return ( +
+ + {rule.text} + {rule.hint} + {rule.priority !== null && ( + + + + + {' '} + {rule.priority} + + )} + + + + + +
+ ); +}; + +const Rules: React.FC = () => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + + const { data, isLoading } = useRules(); + + const handleCreateRule = () => { + dispatch(openModal('EDIT_RULE')); + }; + + const emptyMessage = ; + + return ( + + + + + {data!.map((rule) => ( + + ))} + + + + ); +}; + +export default Rules; diff --git a/src/features/admin/tabs/dashboard.tsx b/src/features/admin/tabs/dashboard.tsx index 903fc9f82..bec02694d 100644 --- a/src/features/admin/tabs/dashboard.tsx +++ b/src/features/admin/tabs/dashboard.tsx @@ -100,6 +100,13 @@ const Dashboard: React.FC = () => { /> )} + {features.adminRules && ( + } + /> + )} + {features.domains && ( > = { 'EDIT_BOOKMARK_FOLDER': EditBookmarkFolderModal, 'EDIT_DOMAIN': EditDomainModal, 'EDIT_FEDERATION': EditFederationModal, + 'EDIT_RULE': EditRuleModal, 'EMBED': EmbedModal, 'EVENT_MAP': EventMapModal, 'EVENT_PARTICIPANTS': EventParticipantsModal, diff --git a/src/features/ui/components/modals/edit-rule-modal.tsx b/src/features/ui/components/modals/edit-rule-modal.tsx new file mode 100644 index 000000000..fe1862da5 --- /dev/null +++ b/src/features/ui/components/modals/edit-rule-modal.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; + +import { useRules } from 'soapbox/api/hooks/admin'; +import { Form, FormGroup, Input, Modal } from 'soapbox/components/ui'; +import { useTextField } from 'soapbox/hooks/forms'; +import { type AdminRule } from 'soapbox/schemas'; +import toast from 'soapbox/toast'; + +const messages = defineMessages({ + save: { id: 'admin.edit_rule.save', defaultMessage: 'Save' }, + ruleTextPlaceholder: { id: 'admin.edit_rule.fields.text_placeholder', defaultMessage: 'Instance rule text' }, + rulePriorityPlaceholder: { id: 'admin.edit_rule.fields.priority_placeholder', defaultMessage: 'Instance rule display priority' }, + ruleCreateSuccess: { id: 'admin.edit_rule.created', defaultMessage: 'Rule created' }, + ruleUpdateSuccess: { id: 'admin.edit_rule.updated', defaultMessage: 'Rule edited' }, +}); + +interface IEditRuleModal { + onClose: (type?: string) => void; + rule?: AdminRule; +} + +const EditRuleModal: React.FC = ({ onClose, rule }) => { + const intl = useIntl(); + + const { createRule, updateRule } = useRules(); + + const text = useTextField(rule?.text); + const priority = useTextField(rule?.priority?.toString()); + + const onClickClose = () => { + onClose('EDIT_RULE'); + }; + + const handleSubmit = () => { + if (rule) { + updateRule({ + id: rule.id, + text: text.value, + priority: isNaN(Number(priority.value)) ? undefined : Number(priority.value), + }, { + onSuccess: () => { + toast.success(messages.ruleUpdateSuccess); + onClickClose(); + }, + }); + } else { + createRule({ + text: text.value, + priority: isNaN(Number(priority.value)) ? undefined : Number(priority.value), + }, { + onSuccess: () => { + toast.success(messages.ruleUpdateSuccess); + onClickClose(); + }, + }); + } + }; + + return ( + + : } + confirmationAction={handleSubmit} + confirmationText={intl.formatMessage(messages.save)} + > +
+ } + > + + + } + > + + +
+
+ ); +}; + +export default EditRuleModal; diff --git a/src/features/ui/index.tsx b/src/features/ui/index.tsx index 97d5445cb..49bb1f566 100644 --- a/src/features/ui/index.tsx +++ b/src/features/ui/index.tsx @@ -142,6 +142,7 @@ import { NostrRelays, Bech32Redirect, Relays, + Rules, } from './util/async-components'; import GlobalHotkeys from './util/global-hotkeys'; import { WrappedRoute } from './util/react-router-helpers'; @@ -332,9 +333,10 @@ const SwitchingColumnsArea: React.FC = ({ children }) => + {features.adminAnnouncements && } {features.domains && } - + {features.adminRules && } diff --git a/src/features/ui/util/async-components.ts b/src/features/ui/util/async-components.ts index 7f9227c99..738cfc847 100644 --- a/src/features/ui/util/async-components.ts +++ b/src/features/ui/util/async-components.ts @@ -173,3 +173,5 @@ export const EditDomainModal = lazy(() => import('soapbox/features/ui/components export const NostrRelays = lazy(() => import('soapbox/features/nostr-relays')); export const Bech32Redirect = lazy(() => import('soapbox/features/nostr/Bech32Redirect')); export const Relays = lazy(() => import('soapbox/features/admin/relays')); +export const Rules = lazy(() => import('soapbox/features/admin/rules')); +export const EditRuleModal = lazy(() => import('soapbox/features/ui/components/modals/edit-rule-modal')); diff --git a/src/schemas/index.ts b/src/schemas/index.ts index dcc4e74e2..e20fd4d4b 100644 --- a/src/schemas/index.ts +++ b/src/schemas/index.ts @@ -18,6 +18,7 @@ export { patronUserSchema, type PatronUser } from './patron'; export { pollSchema, type Poll, type PollOption } from './poll'; export { relationshipSchema, type Relationship } from './relationship'; export { relaySchema, type Relay } from './relay'; +export { ruleSchema, adminRuleSchema, type Rule, type AdminRule } from './rule'; export { statusSchema, type Status } from './status'; export { tagSchema, type Tag } from './tag'; export { tombstoneSchema, type Tombstone } from './tombstone'; diff --git a/src/schemas/rule.ts b/src/schemas/rule.ts index 84f91e991..888ee3d50 100644 --- a/src/schemas/rule.ts +++ b/src/schemas/rule.ts @@ -1,19 +1,25 @@ import { z } from 'zod'; -import { coerceObject } from './utils'; +const baseRuleSchema = z.object({ + id: z.string(), + text: z.string().catch(''), + hint: z.string().catch(''), + rule_type: z.enum(['account', 'content', 'group']).nullable().catch(null), +}); const ruleSchema = z.preprocess((data: any) => { return { ...data, hint: data.hint || data.subtext, }; -}, coerceObject({ - id: z.string(), - text: z.string().catch(''), - hint: z.string().catch(''), - rule_type: z.enum(['account', 'content', 'group']).nullable().catch(null), -})); +}, baseRuleSchema); type Rule = z.infer; -export { ruleSchema, type Rule }; \ No newline at end of file +const adminRuleSchema = baseRuleSchema.extend({ + priority: z.number().nullable().catch(null), +}); + +type AdminRule = z.infer; + +export { ruleSchema, adminRuleSchema, type Rule, type AdminRule }; \ No newline at end of file diff --git a/src/utils/features.ts b/src/utils/features.ts index 8c42da8f4..71a64293b 100644 --- a/src/utils/features.ts +++ b/src/utils/features.ts @@ -203,6 +203,15 @@ const getInstanceFeatures = (instance: Instance) => { */ adminFE: v.software === PLEROMA, + /** + * Ability to manage instance rules by admins. + * @see GET /api/v1/pleroma/admin/rules + * @see POST /api/v1/pleroma/admin/rules + * @see PATCH /api/v1/pleroma/admin/rules/:id + * @see DELETE /api/v1/pleroma/admin/rules/:id + */ + adminRules: v.software === PLEROMA && v.build === REBASED && gte(v.version, '2.4.51'), + /** * Can display announcements set by admins. * @see GET /api/v1/announcements