From 170157570447d30732445f6339b0c7b2fe7617d8 Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 9 Aug 2024 16:21:55 +0200 Subject: [PATCH] Add option to ignore filtered notifications to the web interface (#31342) --- .../mastodon/api/notification_policies.ts | 4 +- .../api_types/notification_policies.ts | 11 +- .../mastodon/components/dropdown_selector.tsx | 2 +- .../components/policy_controls.tsx | 131 +++++++++++---- .../components/select_with_label.tsx | 153 ++++++++++++++++++ .../components/ignore_notifications_modal.jsx | 108 +++++++++++++ .../features/ui/components/modal_root.jsx | 2 + .../features/ui/util/async-components.js | 4 + app/javascript/mastodon/locales/en.json | 19 ++- .../400-24px/person_alert-fill.svg | 1 + .../material-icons/400-24px/person_alert.svg | 1 + .../400-24px/shield_question-fill.svg | 1 + .../400-24px/shield_question.svg | 1 + .../styles/mastodon/components.scss | 7 + 14 files changed, 402 insertions(+), 43 deletions(-) create mode 100644 app/javascript/mastodon/features/notifications/components/select_with_label.tsx create mode 100644 app/javascript/mastodon/features/ui/components/ignore_notifications_modal.jsx create mode 100644 app/javascript/material-icons/400-24px/person_alert-fill.svg create mode 100644 app/javascript/material-icons/400-24px/person_alert.svg create mode 100644 app/javascript/material-icons/400-24px/shield_question-fill.svg create mode 100644 app/javascript/material-icons/400-24px/shield_question.svg diff --git a/app/javascript/mastodon/api/notification_policies.ts b/app/javascript/mastodon/api/notification_policies.ts index 4032134fb5..7747397556 100644 --- a/app/javascript/mastodon/api/notification_policies.ts +++ b/app/javascript/mastodon/api/notification_policies.ts @@ -2,8 +2,8 @@ import { apiRequestGet, apiRequestPut } from 'mastodon/api'; import type { NotificationPolicyJSON } from 'mastodon/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/mastodon/api_types/notification_policies.ts b/app/javascript/mastodon/api_types/notification_policies.ts index 0f4a2d132e..1c3970782c 100644 --- a/app/javascript/mastodon/api_types/notification_policies.ts +++ b/app/javascript/mastodon/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/mastodon/components/dropdown_selector.tsx b/app/javascript/mastodon/components/dropdown_selector.tsx index f8bf96c634..b86d2d0f80 100644 --- a/app/javascript/mastodon/components/dropdown_selector.tsx +++ b/app/javascript/mastodon/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/mastodon/features/notifications/components/policy_controls.tsx b/app/javascript/mastodon/features/notifications/components/policy_controls.tsx index d6bc412994..032c0ea483 100644 --- a/app/javascript/mastodon/features/notifications/components/policy_controls.tsx +++ b/app/javascript/mastodon/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 'mastodon/actions/modal'; import { updateNotificationsPolicy } from 'mastodon/actions/notification_policies'; +import type { AppDispatch } from 'mastodon/store'; import { useAppSelector, useAppDispatch } from 'mastodon/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/mastodon/features/notifications/components/select_with_label.tsx b/app/javascript/mastodon/features/notifications/components/select_with_label.tsx new file mode 100644 index 0000000000..413267c0f8 --- /dev/null +++ b/app/javascript/mastodon/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 'mastodon/components/dropdown_selector'; +import { DropdownSelector } from 'mastodon/components/dropdown_selector'; +import { Icon } from 'mastodon/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/mastodon/features/ui/components/ignore_notifications_modal.jsx b/app/javascript/mastodon/features/ui/components/ignore_notifications_modal.jsx new file mode 100644 index 0000000000..b163b8ce47 --- /dev/null +++ b/app/javascript/mastodon/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 'mastodon/actions/modal'; +import { updateNotificationsPolicy } from 'mastodon/actions/notification_policies'; +import { Button } from 'mastodon/components/button'; +import { Icon } from 'mastodon/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/mastodon/features/ui/components/modal_root.jsx b/app/javascript/mastodon/features/ui/components/modal_root.jsx index 3e900a0667..64933fd1ae 100644 --- a/app/javascript/mastodon/features/ui/components/modal_root.jsx +++ b/app/javascript/mastodon/features/ui/components/modal_root.jsx @@ -17,6 +17,7 @@ import { InteractionModal, SubscribedLanguagesModal, ClosedRegistrationsModal, + IgnoreNotificationsModal, } from 'mastodon/features/ui/util/async-components'; import { getScrollbarWidth } from 'mastodon/utils/scrollbar'; @@ -70,6 +71,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/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index 7c4372d5a6..7e9a7af00a 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -134,6 +134,10 @@ export function ReportModal () { return import(/* webpackChunkName: "modals/report_modal" */'../components/report_modal'); } +export function IgnoreNotificationsModal () { + return import(/* webpackChunkName: "modals/domain_block_modal" */'../components/ignore_notifications_modal'); +} + export function MediaGallery () { return import(/* webpackChunkName: "status/media_gallery" */'../../../components/media_gallery'); } diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 86afa7cd0d..8df1eef8ca 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -356,6 +356,17 @@ "home.pending_critical_update.link": "See updates", "home.pending_critical_update.title": "Critical security update available!", "home.show_announcements": "Show announcements", + "ignore_notifications_modal.disclaimer": "Mastodon cannot inform users that you've ignored their notifications. Ignoring notifications will not stop the messages themselves from being sent.", + "ignore_notifications_modal.filter_instead": "Filter instead", + "ignore_notifications_modal.filter_to_act_users": "Filtering helps avoid potential confusion", + "ignore_notifications_modal.filter_to_avoid_confusion": "Filtering helps avoid potential confusion", + "ignore_notifications_modal.filter_to_review_separately": "You can review filtered notifications speparately", + "ignore_notifications_modal.ignore": "Ignore notifications", + "ignore_notifications_modal.limited_accounts_title": "Ignore notifications from moderated accounts?", + "ignore_notifications_modal.new_accounts_title": "Ignore notifications from new accounts?", + "ignore_notifications_modal.not_followers_title": "Ignore notifications from people not following you?", + "ignore_notifications_modal.not_following_title": "Ignore notifications from people you don't follow?", + "ignore_notifications_modal.private_mentions_title": "Ignore notifications from unsolicited Private Mentions?", "interaction_modal.description.favourite": "With an account on Mastodon, you can favorite this post to let the author know you appreciate it and save it for later.", "interaction_modal.description.follow": "With an account on Mastodon, you can follow {name} to receive their posts in your home feed.", "interaction_modal.description.reblog": "With an account on Mastodon, you can boost this post to share it with your own followers.", @@ -550,6 +561,12 @@ "notifications.permission_denied": "Desktop notifications are unavailable due to previously denied browser permissions request", "notifications.permission_denied_alert": "Desktop notifications can't be enabled, as browser permission has been denied before", "notifications.permission_required": "Desktop notifications are unavailable because the required permission has not been granted.", + "notifications.policy.accept": "Accept", + "notifications.policy.accept_hint": "Show in notifications", + "notifications.policy.drop": "Ignore", + "notifications.policy.drop_hint": "Send to the void, never to be seen again", + "notifications.policy.filter": "Filter", + "notifications.policy.filter_hint": "Send to filtered notifications inbox", "notifications.policy.filter_limited_accounts_hint": "Limited by server moderators", "notifications.policy.filter_limited_accounts_title": "Moderated accounts", "notifications.policy.filter_new_accounts.hint": "Created within the past {days, plural, one {one day} other {# days}}", @@ -560,7 +577,7 @@ "notifications.policy.filter_not_following_title": "People you don't follow", "notifications.policy.filter_private_mentions_hint": "Filtered unless it's in reply to your own mention or if you follow the sender", "notifications.policy.filter_private_mentions_title": "Unsolicited private mentions", - "notifications.policy.title": "Filter out notifications from…", + "notifications.policy.title": "Manage notifications from…", "notifications_permission_banner.enable": "Enable desktop notifications", "notifications_permission_banner.how_to_control": "To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled.", "notifications_permission_banner.title": "Never miss a thing", diff --git a/app/javascript/material-icons/400-24px/person_alert-fill.svg b/app/javascript/material-icons/400-24px/person_alert-fill.svg new file mode 100644 index 0000000000..ddbecc6053 --- /dev/null +++ b/app/javascript/material-icons/400-24px/person_alert-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/material-icons/400-24px/person_alert.svg b/app/javascript/material-icons/400-24px/person_alert.svg new file mode 100644 index 0000000000..292ea32154 --- /dev/null +++ b/app/javascript/material-icons/400-24px/person_alert.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/material-icons/400-24px/shield_question-fill.svg b/app/javascript/material-icons/400-24px/shield_question-fill.svg new file mode 100644 index 0000000000..c647567a00 --- /dev/null +++ b/app/javascript/material-icons/400-24px/shield_question-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/material-icons/400-24px/shield_question.svg b/app/javascript/material-icons/400-24px/shield_question.svg new file mode 100644 index 0000000000..342ac0800e --- /dev/null +++ b/app/javascript/material-icons/400-24px/shield_question.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 3c938ac4c5..0fec52f375 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -877,6 +877,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;