[Glitch] Add option to ignore filtered notifications to the web interface

Port 1701575704 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
pull/2809/head
Claire 2024-08-09 16:21:55 +02:00
parent 99d38167a3
commit 58b9b80be5
9 changed files with 380 additions and 42 deletions

View File

@ -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<NotificationPolicyJSON>('/v1/notifications/policy');
apiRequestGet<NotificationPolicyJSON>('/v2/notifications/policy');
export const apiUpdateNotificationsPolicy = (
policy: Partial<NotificationPolicyJSON>,
) => apiRequestPut<NotificationPolicyJSON>('/v1/notifications/policy', policy);
) => apiRequestPut<NotificationPolicyJSON>('/v2/notifications/policy', policy);

View File

@ -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;

View File

@ -13,7 +13,7 @@ const listenerOptions = supportsPassiveEvents
? { passive: true, capture: true }
: true;
interface SelectItem {
export interface SelectItem {
value: string;
icon?: string;
iconComponent?: IconProp;

View File

@ -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 (
<section>
<h3>
<FormattedMessage
id='notifications.policy.title'
defaultMessage='Filter out notifications from…'
defaultMessage='Manage notifications from…'
/>
</h3>
<div className='column-settings__row'>
<CheckboxWithLabel
checked={notificationPolicy.filter_not_following}
<SelectWithLabel
value={notificationPolicy.for_not_following}
onChange={handleFilterNotFollowing}
options={options}
>
<strong>
<FormattedMessage
@ -81,11 +135,12 @@ export const PolicyControls: React.FC = () => {
defaultMessage='Until you manually approve them'
/>
</span>
</CheckboxWithLabel>
</SelectWithLabel>
<CheckboxWithLabel
checked={notificationPolicy.filter_not_followers}
<SelectWithLabel
value={notificationPolicy.for_not_followers}
onChange={handleFilterNotFollowers}
options={options}
>
<strong>
<FormattedMessage
@ -100,11 +155,12 @@ export const PolicyControls: React.FC = () => {
values={{ days: 3 }}
/>
</span>
</CheckboxWithLabel>
</SelectWithLabel>
<CheckboxWithLabel
checked={notificationPolicy.filter_new_accounts}
<SelectWithLabel
value={notificationPolicy.for_new_accounts}
onChange={handleFilterNewAccounts}
options={options}
>
<strong>
<FormattedMessage
@ -119,11 +175,12 @@ export const PolicyControls: React.FC = () => {
values={{ days: 30 }}
/>
</span>
</CheckboxWithLabel>
</SelectWithLabel>
<CheckboxWithLabel
checked={notificationPolicy.filter_private_mentions}
<SelectWithLabel
value={notificationPolicy.for_private_mentions}
onChange={handleFilterPrivateMentions}
options={options}
>
<strong>
<FormattedMessage
@ -137,9 +194,13 @@ export const PolicyControls: React.FC = () => {
defaultMessage="Filtered unless it's in reply to your own mention or if you follow the sender"
/>
</span>
</CheckboxWithLabel>
</SelectWithLabel>
<CheckboxWithLabel checked disabled onChange={noop}>
<SelectWithLabel
value={notificationPolicy.for_limited_accounts}
onChange={handleFilterLimitedAccounts}
options={options}
>
<strong>
<FormattedMessage
id='notifications.policy.filter_limited_accounts_title'
@ -152,7 +213,7 @@ export const PolicyControls: React.FC = () => {
defaultMessage='Limited by server moderators'
/>
</span>
</CheckboxWithLabel>
</SelectWithLabel>
</div>
</section>
);

View File

@ -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<DropdownProps> = ({
value,
options,
disabled,
onChange,
placement: initialPlacement = 'bottom-end',
}) => {
const activeElementRef = useRef<Element | null>(null);
const containerRef = useRef(null);
const [isOpen, setOpen] = useState<boolean>(false);
const [placement, setPlacement] = useState<Placement>(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<PopperState>) => {
if (state.placement) setPlacement(state.placement);
},
[setPlacement],
);
const valueOption = options.find((item) => item.value === value);
return (
<div ref={containerRef}>
<button
type='button'
onClick={handleToggle}
onMouseDown={handleMouseDown}
onKeyDown={handleKeyDown}
disabled={disabled}
className={classNames('dropdown-button', { active: isOpen })}
>
<span className='dropdown-button__label'>{valueOption?.text}</span>
<Icon id='down' icon={ArrowDropDownIcon} />
</button>
<Overlay
show={isOpen}
offset={[5, 5]}
placement={placement}
flip
target={containerRef}
popperConfig={{ strategy: 'fixed', onFirstUpdate: handleOverlayEnter }}
>
{({ props, placement }) => (
<div {...props}>
<div
className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}
>
<DropdownSelector
items={options}
value={value}
onClose={handleClose}
onChange={onChange}
classNamePrefix='privacy-dropdown'
/>
</div>
</div>
)}
</Overlay>
</div>
);
};
interface Props {
value: string;
options: SelectItem[];
disabled?: boolean;
onChange: (value: string) => void;
}
export const SelectWithLabel: React.FC<PropsWithChildren<Props>> = ({
value,
options,
disabled,
children,
onChange,
}) => {
return (
<label className='app-form__toggle'>
<div className='app-form__toggle__label'>{children}</div>
<div className='app-form__toggle__toggle'>
<div>
<Dropdown
value={value}
onChange={onChange}
disabled={disabled}
options={options}
/>
</div>
</div>
</label>
);
};

View File

@ -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 = <FormattedMessage id='ignore_notifications_modal.not_following_title' defaultMessage="Ignore notifications from people you don't follow?" />;
break;
case 'for_not_followers':
title = <FormattedMessage id='ignore_notifications_modal.not_followers_title' defaultMessage='Ignore notifications from people not following you?' />;
break;
case 'for_new_accounts':
title = <FormattedMessage id='ignore_notifications_modal.new_accounts_title' defaultMessage='Ignore notifications from new accounts?' />;
break;
case 'for_private_mentions':
title = <FormattedMessage id='ignore_notifications_modal.private_mentions_title' defaultMessage='Ignore notifications from unsolicited Private Mentions?' />;
break;
case 'for_limited_accounts':
title = <FormattedMessage id='ignore_notifications_modal.limited_accounts_title' defaultMessage='Ignore notifications from moderated accounts?' />;
break;
}
return (
<div className='modal-root__modal safety-action-modal'>
<div className='safety-action-modal__top'>
<div className='safety-action-modal__header'>
<h1>{title}</h1>
</div>
<div className='safety-action-modal__bullet-points'>
<div>
<div className='safety-action-modal__bullet-points__icon'><Icon icon={InventoryIcon} /></div>
<div><FormattedMessage id='ignore_notifications_modal.filter_to_review_separately' defaultMessage='You can review filtered notifications speparately' /></div>
</div>
<div>
<div className='safety-action-modal__bullet-points__icon'><Icon icon={PersonAlertIcon} /></div>
<div><FormattedMessage id='ignore_notifications_modal.filter_to_act_users' defaultMessage="You'll still be able to accept, reject, or report users" /></div>
</div>
<div>
<div className='safety-action-modal__bullet-points__icon'><Icon icon={ShieldQuestionIcon} /></div>
<div><FormattedMessage id='ignore_notifications_modal.filter_to_avoid_confusion' defaultMessage='Filtering helps avoid potential confusion' /></div>
</div>
</div>
<div>
<FormattedMessage id='ignore_notifications_modal.disclaimer' defaultMessage="Mastodon cannot inform users that you've ignored their notifications. Ignoring notifications will not stop the messages themselves from being sent." />
</div>
</div>
<div className='safety-action-modal__bottom'>
<div className='safety-action-modal__actions'>
<Button onClick={handleSecondaryClick} secondary>
<FormattedMessage id='ignore_notifications_modal.filter_instead' defaultMessage='Filter instead' />
</Button>
<div className='spacer' />
<button onClick={handleCancel} className='link-button'>
<FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />
</button>
<button onClick={handleClick} className='link-button'>
<FormattedMessage id='ignore_notifications_modal.ignore' defaultMessage='Ignore notifications' />
</button>
</div>
</div>
</div>
);
};
IgnoreNotificationsModal.propTypes = {
filterType: PropTypes.string.isRequired,
};
export default IgnoreNotificationsModal;

View File

@ -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 {

View File

@ -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');
}

View File

@ -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;