diff --git a/app/javascript/flavours/glitch/api/notification_policies.ts b/app/javascript/flavours/glitch/api/notification_policies.ts index e52ea64f41..1db79a6e74 100644 --- a/app/javascript/flavours/glitch/api/notification_policies.ts +++ b/app/javascript/flavours/glitch/api/notification_policies.ts @@ -2,8 +2,8 @@ import { apiRequestGet, apiRequestPut } from 'flavours/glitch/api'; import type { NotificationPolicyJSON } from 'flavours/glitch/api_types/notification_policies'; export const apiGetNotificationPolicy = () => - apiRequestGet('/v1/notifications/policy'); + apiRequestGet('/v2/notifications/policy'); export const apiUpdateNotificationsPolicy = ( policy: Partial, -) => apiRequestPut('/v1/notifications/policy', policy); +) => apiRequestPut('/v2/notifications/policy', policy); diff --git a/app/javascript/flavours/glitch/api_types/notification_policies.ts b/app/javascript/flavours/glitch/api_types/notification_policies.ts index 0f4a2d132e..1c3970782c 100644 --- a/app/javascript/flavours/glitch/api_types/notification_policies.ts +++ b/app/javascript/flavours/glitch/api_types/notification_policies.ts @@ -1,10 +1,13 @@ // See app/serializers/rest/notification_policy_serializer.rb +export type NotificationPolicyValue = 'accept' | 'filter' | 'drop'; + export interface NotificationPolicyJSON { - filter_not_following: boolean; - filter_not_followers: boolean; - filter_new_accounts: boolean; - filter_private_mentions: boolean; + for_not_following: NotificationPolicyValue; + for_not_followers: NotificationPolicyValue; + for_new_accounts: NotificationPolicyValue; + for_private_mentions: NotificationPolicyValue; + for_limited_accounts: NotificationPolicyValue; summary: { pending_requests_count: number; pending_notifications_count: number; diff --git a/app/javascript/flavours/glitch/components/dropdown_selector.tsx b/app/javascript/flavours/glitch/components/dropdown_selector.tsx index f8bf96c634..b86d2d0f80 100644 --- a/app/javascript/flavours/glitch/components/dropdown_selector.tsx +++ b/app/javascript/flavours/glitch/components/dropdown_selector.tsx @@ -13,7 +13,7 @@ const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true; -interface SelectItem { +export interface SelectItem { value: string; icon?: string; iconComponent?: IconProp; diff --git a/app/javascript/flavours/glitch/features/notifications/components/policy_controls.tsx b/app/javascript/flavours/glitch/features/notifications/components/policy_controls.tsx index 5982db2923..da5d3960b4 100644 --- a/app/javascript/flavours/glitch/features/notifications/components/policy_controls.tsx +++ b/app/javascript/flavours/glitch/features/notifications/components/policy_controls.tsx @@ -1,16 +1,52 @@ import { useCallback } from 'react'; -import { FormattedMessage } from 'react-intl'; +import { useIntl, defineMessages, FormattedMessage } from 'react-intl'; +import { openModal } from 'flavours/glitch/actions/modal'; import { updateNotificationsPolicy } from 'flavours/glitch/actions/notification_policies'; +import type { AppDispatch } from 'flavours/glitch/store'; import { useAppSelector, useAppDispatch } from 'flavours/glitch/store'; -import { CheckboxWithLabel } from './checkbox_with_label'; +import { SelectWithLabel } from './select_with_label'; -// eslint-disable-next-line @typescript-eslint/no-empty-function -const noop = () => {}; +const messages = defineMessages({ + accept: { id: 'notifications.policy.accept', defaultMessage: 'Accept' }, + accept_hint: { + id: 'notifications.policy.accept_hint', + defaultMessage: 'Show in notifications', + }, + filter: { id: 'notifications.policy.filter', defaultMessage: 'Filter' }, + filter_hint: { + id: 'notifications.policy.filter_hint', + defaultMessage: 'Send to filtered notifications inbox', + }, + drop: { id: 'notifications.policy.drop', defaultMessage: 'Ignore' }, + drop_hint: { + id: 'notifications.policy.drop_hint', + defaultMessage: 'Send to the void, never to be seen again', + }, +}); + +// TODO: change the following when we change the API +const changeFilter = ( + dispatch: AppDispatch, + filterType: string, + value: string, +) => { + if (value === 'drop') { + dispatch( + openModal({ + modalType: 'IGNORE_NOTIFICATIONS', + modalProps: { filterType }, + }), + ); + } else { + void dispatch(updateNotificationsPolicy({ [filterType]: value })); + } +}; export const PolicyControls: React.FC = () => { + const intl = useIntl(); const dispatch = useAppDispatch(); const notificationPolicy = useAppSelector( @@ -18,56 +54,74 @@ export const PolicyControls: React.FC = () => { ); const handleFilterNotFollowing = useCallback( - (checked: boolean) => { - void dispatch( - updateNotificationsPolicy({ filter_not_following: checked }), - ); + (value: string) => { + changeFilter(dispatch, 'for_not_following', value); }, [dispatch], ); const handleFilterNotFollowers = useCallback( - (checked: boolean) => { - void dispatch( - updateNotificationsPolicy({ filter_not_followers: checked }), - ); + (value: string) => { + changeFilter(dispatch, 'for_not_followers', value); }, [dispatch], ); const handleFilterNewAccounts = useCallback( - (checked: boolean) => { - void dispatch( - updateNotificationsPolicy({ filter_new_accounts: checked }), - ); + (value: string) => { + changeFilter(dispatch, 'for_new_accounts', value); }, [dispatch], ); const handleFilterPrivateMentions = useCallback( - (checked: boolean) => { - void dispatch( - updateNotificationsPolicy({ filter_private_mentions: checked }), - ); + (value: string) => { + changeFilter(dispatch, 'for_private_mentions', value); + }, + [dispatch], + ); + + const handleFilterLimitedAccounts = useCallback( + (value: string) => { + changeFilter(dispatch, 'for_limited_accounts', value); }, [dispatch], ); if (!notificationPolicy) return null; + const options = [ + { + value: 'accept', + text: intl.formatMessage(messages.accept), + meta: intl.formatMessage(messages.accept_hint), + }, + { + value: 'filter', + text: intl.formatMessage(messages.filter), + meta: intl.formatMessage(messages.filter_hint), + }, + { + value: 'drop', + text: intl.formatMessage(messages.drop), + meta: intl.formatMessage(messages.drop_hint), + }, + ]; + return (

- { defaultMessage='Until you manually approve them' /> - + - { values={{ days: 3 }} /> - + - { values={{ days: 30 }} /> - + - { defaultMessage="Filtered unless it's in reply to your own mention or if you follow the sender" /> - + - + { defaultMessage='Limited by server moderators' /> - +
); diff --git a/app/javascript/flavours/glitch/features/notifications/components/select_with_label.tsx b/app/javascript/flavours/glitch/features/notifications/components/select_with_label.tsx new file mode 100644 index 0000000000..dbdfbdffa6 --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/components/select_with_label.tsx @@ -0,0 +1,153 @@ +import type { PropsWithChildren } from 'react'; +import { useCallback, useState, useRef } from 'react'; + +import classNames from 'classnames'; + +import type { Placement, State as PopperState } from '@popperjs/core'; +import Overlay from 'react-overlays/Overlay'; + +import ArrowDropDownIcon from '@/material-icons/400-24px/arrow_drop_down.svg?react'; +import type { SelectItem } from 'flavours/glitch/components/dropdown_selector'; +import { DropdownSelector } from 'flavours/glitch/components/dropdown_selector'; +import { Icon } from 'flavours/glitch/components/icon'; + +interface DropdownProps { + value: string; + options: SelectItem[]; + disabled?: boolean; + onChange: (value: string) => void; + placement?: Placement; +} + +const Dropdown: React.FC = ({ + value, + options, + disabled, + onChange, + placement: initialPlacement = 'bottom-end', +}) => { + const activeElementRef = useRef(null); + const containerRef = useRef(null); + const [isOpen, setOpen] = useState(false); + const [placement, setPlacement] = useState(initialPlacement); + + const handleToggle = useCallback(() => { + if ( + isOpen && + activeElementRef.current && + activeElementRef.current instanceof HTMLElement + ) { + activeElementRef.current.focus({ preventScroll: true }); + } + + setOpen(!isOpen); + }, [isOpen, setOpen]); + + const handleMouseDown = useCallback(() => { + if (!isOpen) activeElementRef.current = document.activeElement; + }, [isOpen]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + switch (e.key) { + case ' ': + case 'Enter': + if (!isOpen) activeElementRef.current = document.activeElement; + break; + } + }, + [isOpen], + ); + + const handleClose = useCallback(() => { + if ( + isOpen && + activeElementRef.current && + activeElementRef.current instanceof HTMLElement + ) + activeElementRef.current.focus({ preventScroll: true }); + setOpen(false); + }, [isOpen]); + + const handleOverlayEnter = useCallback( + (state: Partial) => { + if (state.placement) setPlacement(state.placement); + }, + [setPlacement], + ); + + const valueOption = options.find((item) => item.value === value); + + return ( +
+ + + + {({ props, placement }) => ( +
+
+ +
+
+ )} +
+
+ ); +}; + +interface Props { + value: string; + options: SelectItem[]; + disabled?: boolean; + onChange: (value: string) => void; +} + +export const SelectWithLabel: React.FC> = ({ + value, + options, + disabled, + children, + onChange, +}) => { + return ( + + ); +}; diff --git a/app/javascript/flavours/glitch/features/ui/components/ignore_notifications_modal.jsx b/app/javascript/flavours/glitch/features/ui/components/ignore_notifications_modal.jsx new file mode 100644 index 0000000000..4c7cb21d7d --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/ignore_notifications_modal.jsx @@ -0,0 +1,108 @@ +import PropTypes from 'prop-types'; +import { useCallback } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { useDispatch } from 'react-redux'; + +import InventoryIcon from '@/material-icons/400-24px/inventory_2.svg?react'; +import PersonAlertIcon from '@/material-icons/400-24px/person_alert.svg?react'; +import ShieldQuestionIcon from '@/material-icons/400-24px/shield_question.svg?react'; +import { closeModal } from 'flavours/glitch/actions/modal'; +import { updateNotificationsPolicy } from 'flavours/glitch/actions/notification_policies'; +import { Button } from 'flavours/glitch/components/button'; +import { Icon } from 'flavours/glitch/components/icon'; + +export const IgnoreNotificationsModal = ({ filterType }) => { + const dispatch = useDispatch(); + + const handleClick = useCallback(() => { + dispatch(closeModal({ modalType: undefined, ignoreFocus: false })); + void dispatch(updateNotificationsPolicy({ [filterType]: 'drop' })); + }, [dispatch, filterType]); + + const handleSecondaryClick = useCallback(() => { + dispatch(closeModal({ modalType: undefined, ignoreFocus: false })); + void dispatch(updateNotificationsPolicy({ [filterType]: 'filter' })); + }, [dispatch, filterType]); + + const handleCancel = useCallback(() => { + dispatch(closeModal({ modalType: undefined, ignoreFocus: false })); + }, [dispatch]); + + let title = null; + + switch(filterType) { + case 'for_not_following': + title = ; + break; + case 'for_not_followers': + title = ; + break; + case 'for_new_accounts': + title = ; + break; + case 'for_private_mentions': + title = ; + break; + case 'for_limited_accounts': + title = ; + break; + } + + return ( +
+
+
+

{title}

+
+ +
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+ +
+ +
+
+ + +
+
+ + +
+ + + + +
+
+
+ ); +}; + +IgnoreNotificationsModal.propTypes = { + filterType: PropTypes.string.isRequired, +}; + +export default IgnoreNotificationsModal; diff --git a/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx b/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx index 0bc393f7e8..64c6b52c31 100644 --- a/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx +++ b/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx @@ -19,6 +19,7 @@ import { InteractionModal, SubscribedLanguagesModal, ClosedRegistrationsModal, + IgnoreNotificationsModal, } from 'flavours/glitch/features/ui/util/async-components'; import { getScrollbarWidth } from 'flavours/glitch/utils/scrollbar'; @@ -80,6 +81,7 @@ export const MODAL_COMPONENTS = { 'SUBSCRIBED_LANGUAGES': SubscribedLanguagesModal, 'INTERACTION': InteractionModal, 'CLOSED_REGISTRATIONS': ClosedRegistrationsModal, + 'IGNORE_NOTIFICATIONS': IgnoreNotificationsModal, }; export default class ModalRoot extends PureComponent { diff --git a/app/javascript/flavours/glitch/features/ui/util/async-components.js b/app/javascript/flavours/glitch/features/ui/util/async-components.js index e334e1a3b6..c7f2e6cff9 100644 --- a/app/javascript/flavours/glitch/features/ui/util/async-components.js +++ b/app/javascript/flavours/glitch/features/ui/util/async-components.js @@ -146,6 +146,10 @@ export function SettingsModal () { return import(/* webpackChunkName: "flavours/glitch/async/settings_modal" */'../../local_settings'); } +export function IgnoreNotificationsModal () { + return import(/* webpackChunkName: "flavours/glitch/async/ignore_notifications_modal" */'../components/ignore_notifications_modal'); +} + export function MediaGallery () { return import(/* webpackChunkName: "flavours/glitch/async/media_gallery" */'../../../components/media_gallery'); } diff --git a/app/javascript/flavours/glitch/styles/components.scss b/app/javascript/flavours/glitch/styles/components.scss index aae93a3871..ebae4e8bcc 100644 --- a/app/javascript/flavours/glitch/styles/components.scss +++ b/app/javascript/flavours/glitch/styles/components.scss @@ -926,6 +926,13 @@ body > [data-popper-placement] { text-overflow: ellipsis; white-space: nowrap; + &[disabled] { + cursor: default; + color: $highlight-text-color; + border-color: $highlight-text-color; + opacity: 0.5; + } + .icon { width: 15px; height: 15px;