Merge commit '658addcbf783f6baa922d11c9524ebb9ddbcbc59' into glitch-soc/merge-upstream

shrike
Claire 2024-08-09 17:15:32 +02:00
commit 31a00c0c1a
57 changed files with 1506 additions and 375 deletions

View File

@ -8,12 +8,12 @@ class Api::V1::Notifications::PoliciesController < Api::BaseController
before_action :set_policy before_action :set_policy
def show def show
render json: @policy, serializer: REST::NotificationPolicySerializer render json: @policy, serializer: REST::V1::NotificationPolicySerializer
end end
def update def update
@policy.update!(resource_params) @policy.update!(resource_params)
render json: @policy, serializer: REST::NotificationPolicySerializer render json: @policy, serializer: REST::V1::NotificationPolicySerializer
end end
private private

View File

@ -0,0 +1,38 @@
# frozen_string_literal: true
class Api::V2::Notifications::PoliciesController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:notifications' }, only: :show
before_action -> { doorkeeper_authorize! :write, :'write:notifications' }, only: :update
before_action :require_user!
before_action :set_policy
def show
render json: @policy, serializer: REST::NotificationPolicySerializer
end
def update
@policy.update!(resource_params)
render json: @policy, serializer: REST::NotificationPolicySerializer
end
private
def set_policy
@policy = NotificationPolicy.find_or_initialize_by(account: current_account)
with_read_replica do
@policy.summarize!
end
end
def resource_params
params.permit(
:for_not_following,
:for_not_followers,
:for_new_accounts,
:for_private_mentions,
:for_limited_accounts
)
end
end

View File

@ -64,6 +64,14 @@ export const NOTIFICATION_REQUEST_DISMISS_REQUEST = 'NOTIFICATION_REQUEST_DISMIS
export const NOTIFICATION_REQUEST_DISMISS_SUCCESS = 'NOTIFICATION_REQUEST_DISMISS_SUCCESS'; export const NOTIFICATION_REQUEST_DISMISS_SUCCESS = 'NOTIFICATION_REQUEST_DISMISS_SUCCESS';
export const NOTIFICATION_REQUEST_DISMISS_FAIL = 'NOTIFICATION_REQUEST_DISMISS_FAIL'; export const NOTIFICATION_REQUEST_DISMISS_FAIL = 'NOTIFICATION_REQUEST_DISMISS_FAIL';
export const NOTIFICATION_REQUESTS_ACCEPT_REQUEST = 'NOTIFICATION_REQUESTS_ACCEPT_REQUEST';
export const NOTIFICATION_REQUESTS_ACCEPT_SUCCESS = 'NOTIFICATION_REQUESTS_ACCEPT_SUCCESS';
export const NOTIFICATION_REQUESTS_ACCEPT_FAIL = 'NOTIFICATION_REQUESTS_ACCEPT_FAIL';
export const NOTIFICATION_REQUESTS_DISMISS_REQUEST = 'NOTIFICATION_REQUESTS_DISMISS_REQUEST';
export const NOTIFICATION_REQUESTS_DISMISS_SUCCESS = 'NOTIFICATION_REQUESTS_DISMISS_SUCCESS';
export const NOTIFICATION_REQUESTS_DISMISS_FAIL = 'NOTIFICATION_REQUESTS_DISMISS_FAIL';
export const NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST = 'NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST'; export const NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST = 'NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST';
export const NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS = 'NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS'; export const NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS = 'NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS';
export const NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL = 'NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL'; export const NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL = 'NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL';
@ -496,6 +504,62 @@ export const dismissNotificationRequestFail = (id, error) => ({
error, error,
}); });
export const acceptNotificationRequests = (ids) => (dispatch, getState) => {
const count = ids.reduce((count, id) => count + selectNotificationCountForRequest(getState(), id), 0);
dispatch(acceptNotificationRequestsRequest(ids));
api().post(`/api/v1/notifications/requests/accept`, { id: ids }).then(() => {
dispatch(acceptNotificationRequestsSuccess(ids));
dispatch(decreasePendingNotificationsCount(count));
}).catch(err => {
dispatch(acceptNotificationRequestFail(ids, err));
});
};
export const acceptNotificationRequestsRequest = ids => ({
type: NOTIFICATION_REQUESTS_ACCEPT_REQUEST,
ids,
});
export const acceptNotificationRequestsSuccess = ids => ({
type: NOTIFICATION_REQUESTS_ACCEPT_SUCCESS,
ids,
});
export const acceptNotificationRequestsFail = (ids, error) => ({
type: NOTIFICATION_REQUESTS_ACCEPT_FAIL,
ids,
error,
});
export const dismissNotificationRequests = (ids) => (dispatch, getState) => {
const count = ids.reduce((count, id) => count + selectNotificationCountForRequest(getState(), id), 0);
dispatch(acceptNotificationRequestsRequest(ids));
api().post(`/api/v1/notifications/requests/dismiss`, { id: ids }).then(() => {
dispatch(dismissNotificationRequestsSuccess(ids));
dispatch(decreasePendingNotificationsCount(count));
}).catch(err => {
dispatch(dismissNotificationRequestFail(ids, err));
});
};
export const dismissNotificationRequestsRequest = ids => ({
type: NOTIFICATION_REQUESTS_DISMISS_REQUEST,
ids,
});
export const dismissNotificationRequestsSuccess = ids => ({
type: NOTIFICATION_REQUESTS_DISMISS_SUCCESS,
ids,
});
export const dismissNotificationRequestsFail = (ids, error) => ({
type: NOTIFICATION_REQUESTS_DISMISS_FAIL,
ids,
error,
});
export const fetchNotificationsForRequest = accountId => (dispatch, getState) => { export const fetchNotificationsForRequest = accountId => (dispatch, getState) => {
const current = getState().getIn(['notificationRequests', 'current']); const current = getState().getIn(['notificationRequests', 'current']);
const params = { account_id: accountId }; const params = { account_id: accountId };

View File

@ -2,8 +2,8 @@ import { apiRequestGet, apiRequestPut } from 'mastodon/api';
import type { NotificationPolicyJSON } from 'mastodon/api_types/notification_policies'; import type { NotificationPolicyJSON } from 'mastodon/api_types/notification_policies';
export const apiGetNotificationPolicy = () => export const apiGetNotificationPolicy = () =>
apiRequestGet<NotificationPolicyJSON>('/v1/notifications/policy'); apiRequestGet<NotificationPolicyJSON>('/v2/notifications/policy');
export const apiUpdateNotificationsPolicy = ( export const apiUpdateNotificationsPolicy = (
policy: Partial<NotificationPolicyJSON>, 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 // See app/serializers/rest/notification_policy_serializer.rb
export type NotificationPolicyValue = 'accept' | 'filter' | 'drop';
export interface NotificationPolicyJSON { export interface NotificationPolicyJSON {
filter_not_following: boolean; for_not_following: NotificationPolicyValue;
filter_not_followers: boolean; for_not_followers: NotificationPolicyValue;
filter_new_accounts: boolean; for_new_accounts: NotificationPolicyValue;
filter_private_mentions: boolean; for_private_mentions: NotificationPolicyValue;
for_limited_accounts: NotificationPolicyValue;
summary: { summary: {
pending_requests_count: number; pending_requests_count: number;
pending_notifications_count: number; pending_notifications_count: number;

View File

@ -1,5 +1,6 @@
import classNames from 'classnames'; import classNames from 'classnames';
import CheckIndeterminateSmallIcon from '@/material-icons/400-24px/check_indeterminate_small.svg?react';
import DoneIcon from '@/material-icons/400-24px/done.svg?react'; import DoneIcon from '@/material-icons/400-24px/done.svg?react';
import { Icon } from './icon'; import { Icon } from './icon';
@ -7,6 +8,7 @@ import { Icon } from './icon';
interface Props { interface Props {
value: string; value: string;
checked: boolean; checked: boolean;
indeterminate: boolean;
name: string; name: string;
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void; onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
label: React.ReactNode; label: React.ReactNode;
@ -16,6 +18,7 @@ export const CheckBox: React.FC<Props> = ({
name, name,
value, value,
checked, checked,
indeterminate,
onChange, onChange,
label, label,
}) => { }) => {
@ -29,8 +32,14 @@ export const CheckBox: React.FC<Props> = ({
onChange={onChange} onChange={onChange}
/> />
<span className={classNames('check-box__input', { checked })}> <span
{checked && <Icon id='check' icon={DoneIcon} />} className={classNames('check-box__input', { checked, indeterminate })}
>
{indeterminate ? (
<Icon id='indeterminate' icon={CheckIndeterminateSmallIcon} />
) : (
checked && <Icon id='check' icon={DoneIcon} />
)}
</span> </span>
<span>{label}</span> <span>{label}</span>

View File

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

View File

@ -3,15 +3,21 @@ import { useCallback } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { Link } from 'react-router-dom'; import classNames from 'classnames';
import { Link, useHistory } from 'react-router-dom';
import { useSelector, useDispatch } from 'react-redux'; import { useSelector, useDispatch } from 'react-redux';
import DeleteIcon from '@/material-icons/400-24px/delete.svg?react'; import DeleteIcon from '@/material-icons/400-24px/delete.svg?react';
import DoneIcon from '@/material-icons/400-24px/done.svg?react'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import { initBlockModal } from 'mastodon/actions/blocks';
import { initMuteModal } from 'mastodon/actions/mutes';
import { acceptNotificationRequest, dismissNotificationRequest } from 'mastodon/actions/notifications'; import { acceptNotificationRequest, dismissNotificationRequest } from 'mastodon/actions/notifications';
import { initReport } from 'mastodon/actions/reports';
import { Avatar } from 'mastodon/components/avatar'; import { Avatar } from 'mastodon/components/avatar';
import { CheckBox } from 'mastodon/components/check_box';
import { IconButton } from 'mastodon/components/icon_button'; import { IconButton } from 'mastodon/components/icon_button';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import { makeGetAccount } from 'mastodon/selectors'; import { makeGetAccount } from 'mastodon/selectors';
import { toCappedNumber } from 'mastodon/utils/numbers'; import { toCappedNumber } from 'mastodon/utils/numbers';
@ -20,12 +26,18 @@ const getAccount = makeGetAccount();
const messages = defineMessages({ const messages = defineMessages({
accept: { id: 'notification_requests.accept', defaultMessage: 'Accept' }, accept: { id: 'notification_requests.accept', defaultMessage: 'Accept' },
dismiss: { id: 'notification_requests.dismiss', defaultMessage: 'Dismiss' }, dismiss: { id: 'notification_requests.dismiss', defaultMessage: 'Dismiss' },
view: { id: 'notification_requests.view', defaultMessage: 'View notifications' },
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
block: { id: 'account.block', defaultMessage: 'Block @{name}' },
report: { id: 'status.report', defaultMessage: 'Report @{name}' },
more: { id: 'status.more', defaultMessage: 'More' },
}); });
export const NotificationRequest = ({ id, accountId, notificationsCount }) => { export const NotificationRequest = ({ id, accountId, notificationsCount, checked, showCheckbox, toggleCheck }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const account = useSelector(state => getAccount(state, accountId)); const account = useSelector(state => getAccount(state, accountId));
const intl = useIntl(); const intl = useIntl();
const { push: historyPush } = useHistory();
const handleDismiss = useCallback(() => { const handleDismiss = useCallback(() => {
dispatch(dismissNotificationRequest(id)); dispatch(dismissNotificationRequest(id));
@ -35,9 +47,51 @@ export const NotificationRequest = ({ id, accountId, notificationsCount }) => {
dispatch(acceptNotificationRequest(id)); dispatch(acceptNotificationRequest(id));
}, [dispatch, id]); }, [dispatch, id]);
const handleMute = useCallback(() => {
dispatch(initMuteModal(account));
}, [dispatch, account]);
const handleBlock = useCallback(() => {
dispatch(initBlockModal(account));
}, [dispatch, account]);
const handleReport = useCallback(() => {
dispatch(initReport(account));
}, [dispatch, account]);
const handleView = useCallback(() => {
historyPush(`/notifications/requests/${id}`);
}, [historyPush, id]);
const menu = [
{ text: intl.formatMessage(messages.view), action: handleView },
null,
{ text: intl.formatMessage(messages.accept), action: handleAccept },
null,
{ text: intl.formatMessage(messages.mute, { name: account.username }), action: handleMute, dangerous: true },
{ text: intl.formatMessage(messages.block, { name: account.username }), action: handleBlock, dangerous: true },
{ text: intl.formatMessage(messages.report, { name: account.username }), action: handleReport, dangerous: true },
];
const handleCheck = useCallback(() => {
toggleCheck(id);
}, [toggleCheck, id]);
const handleClick = useCallback((e) => {
if (showCheckbox) {
toggleCheck(id);
e.preventDefault();
e.stopPropagation();
}
}, [toggleCheck, id, showCheckbox]);
return ( return (
<div className='notification-request'> /* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- this is just a minor affordance, but we will need a comprehensive accessibility pass */
<Link to={`/notifications/requests/${id}`} className='notification-request__link'> <div className={classNames('notification-request', showCheckbox && 'notification-request--forced-checkbox')} onClick={handleClick}>
<div className='notification-request__checkbox' aria-hidden={!showCheckbox}>
<CheckBox checked={checked} onChange={handleCheck} />
</div>
<Link to={`/notifications/requests/${id}`} className='notification-request__link' onClick={handleClick} title={account?.acct}>
<Avatar account={account} size={40} counter={toCappedNumber(notificationsCount)} /> <Avatar account={account} size={40} counter={toCappedNumber(notificationsCount)} />
<div className='notification-request__name'> <div className='notification-request__name'>
@ -51,7 +105,13 @@ export const NotificationRequest = ({ id, accountId, notificationsCount }) => {
<div className='notification-request__actions'> <div className='notification-request__actions'>
<IconButton iconComponent={DeleteIcon} onClick={handleDismiss} title={intl.formatMessage(messages.dismiss)} /> <IconButton iconComponent={DeleteIcon} onClick={handleDismiss} title={intl.formatMessage(messages.dismiss)} />
<IconButton iconComponent={DoneIcon} onClick={handleAccept} title={intl.formatMessage(messages.accept)} /> <DropdownMenuContainer
items={menu}
icons='ellipsis-h'
iconComponent={MoreHorizIcon}
direction='right'
title={intl.formatMessage(messages.more)}
/>
</div> </div>
</div> </div>
); );
@ -61,4 +121,7 @@ NotificationRequest.propTypes = {
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,
accountId: PropTypes.string.isRequired, accountId: PropTypes.string.isRequired,
notificationsCount: PropTypes.string.isRequired, notificationsCount: PropTypes.string.isRequired,
checked: PropTypes.bool,
showCheckbox: PropTypes.bool,
toggleCheck: PropTypes.func,
}; };

View File

@ -1,16 +1,52 @@
import { useCallback } from 'react'; 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 { updateNotificationsPolicy } from 'mastodon/actions/notification_policies';
import type { AppDispatch } from 'mastodon/store';
import { useAppSelector, useAppDispatch } 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 messages = defineMessages({
const noop = () => {}; 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 = () => { export const PolicyControls: React.FC = () => {
const intl = useIntl();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const notificationPolicy = useAppSelector( const notificationPolicy = useAppSelector(
@ -18,56 +54,74 @@ export const PolicyControls: React.FC = () => {
); );
const handleFilterNotFollowing = useCallback( const handleFilterNotFollowing = useCallback(
(checked: boolean) => { (value: string) => {
void dispatch( changeFilter(dispatch, 'for_not_following', value);
updateNotificationsPolicy({ filter_not_following: checked }),
);
}, },
[dispatch], [dispatch],
); );
const handleFilterNotFollowers = useCallback( const handleFilterNotFollowers = useCallback(
(checked: boolean) => { (value: string) => {
void dispatch( changeFilter(dispatch, 'for_not_followers', value);
updateNotificationsPolicy({ filter_not_followers: checked }),
);
}, },
[dispatch], [dispatch],
); );
const handleFilterNewAccounts = useCallback( const handleFilterNewAccounts = useCallback(
(checked: boolean) => { (value: string) => {
void dispatch( changeFilter(dispatch, 'for_new_accounts', value);
updateNotificationsPolicy({ filter_new_accounts: checked }),
);
}, },
[dispatch], [dispatch],
); );
const handleFilterPrivateMentions = useCallback( const handleFilterPrivateMentions = useCallback(
(checked: boolean) => { (value: string) => {
void dispatch( changeFilter(dispatch, 'for_private_mentions', value);
updateNotificationsPolicy({ filter_private_mentions: checked }), },
[dispatch],
); );
const handleFilterLimitedAccounts = useCallback(
(value: string) => {
changeFilter(dispatch, 'for_limited_accounts', value);
}, },
[dispatch], [dispatch],
); );
if (!notificationPolicy) return null; 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 ( return (
<section> <section>
<h3> <h3>
<FormattedMessage <FormattedMessage
id='notifications.policy.title' id='notifications.policy.title'
defaultMessage='Filter out notifications from…' defaultMessage='Manage notifications from…'
/> />
</h3> </h3>
<div className='column-settings__row'> <div className='column-settings__row'>
<CheckboxWithLabel <SelectWithLabel
checked={notificationPolicy.filter_not_following} value={notificationPolicy.for_not_following}
onChange={handleFilterNotFollowing} onChange={handleFilterNotFollowing}
options={options}
> >
<strong> <strong>
<FormattedMessage <FormattedMessage
@ -81,11 +135,12 @@ export const PolicyControls: React.FC = () => {
defaultMessage='Until you manually approve them' defaultMessage='Until you manually approve them'
/> />
</span> </span>
</CheckboxWithLabel> </SelectWithLabel>
<CheckboxWithLabel <SelectWithLabel
checked={notificationPolicy.filter_not_followers} value={notificationPolicy.for_not_followers}
onChange={handleFilterNotFollowers} onChange={handleFilterNotFollowers}
options={options}
> >
<strong> <strong>
<FormattedMessage <FormattedMessage
@ -100,11 +155,12 @@ export const PolicyControls: React.FC = () => {
values={{ days: 3 }} values={{ days: 3 }}
/> />
</span> </span>
</CheckboxWithLabel> </SelectWithLabel>
<CheckboxWithLabel <SelectWithLabel
checked={notificationPolicy.filter_new_accounts} value={notificationPolicy.for_new_accounts}
onChange={handleFilterNewAccounts} onChange={handleFilterNewAccounts}
options={options}
> >
<strong> <strong>
<FormattedMessage <FormattedMessage
@ -119,11 +175,12 @@ export const PolicyControls: React.FC = () => {
values={{ days: 30 }} values={{ days: 30 }}
/> />
</span> </span>
</CheckboxWithLabel> </SelectWithLabel>
<CheckboxWithLabel <SelectWithLabel
checked={notificationPolicy.filter_private_mentions} value={notificationPolicy.for_private_mentions}
onChange={handleFilterPrivateMentions} onChange={handleFilterPrivateMentions}
options={options}
> >
<strong> <strong>
<FormattedMessage <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" defaultMessage="Filtered unless it's in reply to your own mention or if you follow the sender"
/> />
</span> </span>
</CheckboxWithLabel> </SelectWithLabel>
<CheckboxWithLabel checked disabled onChange={noop}> <SelectWithLabel
value={notificationPolicy.for_limited_accounts}
onChange={handleFilterLimitedAccounts}
options={options}
>
<strong> <strong>
<FormattedMessage <FormattedMessage
id='notifications.policy.filter_limited_accounts_title' id='notifications.policy.filter_limited_accounts_title'
@ -152,7 +213,7 @@ export const PolicyControls: React.FC = () => {
defaultMessage='Limited by server moderators' defaultMessage='Limited by server moderators'
/> />
</span> </span>
</CheckboxWithLabel> </SelectWithLabel>
</div> </div>
</section> </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 '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<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

@ -1,5 +1,5 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useRef, useCallback, useEffect } from 'react'; import { useRef, useCallback, useEffect, useState } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
@ -8,11 +8,15 @@ import { Helmet } from 'react-helmet';
import { useSelector, useDispatch } from 'react-redux'; import { useSelector, useDispatch } from 'react-redux';
import InventoryIcon from '@/material-icons/400-24px/inventory_2.svg?react'; import InventoryIcon from '@/material-icons/400-24px/inventory_2.svg?react';
import { fetchNotificationRequests, expandNotificationRequests } from 'mastodon/actions/notifications'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import { openModal } from 'mastodon/actions/modal';
import { fetchNotificationRequests, expandNotificationRequests, acceptNotificationRequests, dismissNotificationRequests } from 'mastodon/actions/notifications';
import { changeSetting } from 'mastodon/actions/settings'; import { changeSetting } from 'mastodon/actions/settings';
import { CheckBox } from 'mastodon/components/check_box';
import Column from 'mastodon/components/column'; import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header'; import ColumnHeader from 'mastodon/components/column_header';
import ScrollableList from 'mastodon/components/scrollable_list'; import ScrollableList from 'mastodon/components/scrollable_list';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import { NotificationRequest } from './components/notification_request'; import { NotificationRequest } from './components/notification_request';
import { PolicyControls } from './components/policy_controls'; import { PolicyControls } from './components/policy_controls';
@ -20,7 +24,18 @@ import SettingToggle from './components/setting_toggle';
const messages = defineMessages({ const messages = defineMessages({
title: { id: 'notification_requests.title', defaultMessage: 'Filtered notifications' }, title: { id: 'notification_requests.title', defaultMessage: 'Filtered notifications' },
maximize: { id: 'notification_requests.maximize', defaultMessage: 'Maximize' } maximize: { id: 'notification_requests.maximize', defaultMessage: 'Maximize' },
more: { id: 'status.more', defaultMessage: 'More' },
acceptAll: { id: 'notification_requests.accept_all', defaultMessage: 'Accept all' },
dismissAll: { id: 'notification_requests.dismiss_all', defaultMessage: 'Dismiss all' },
acceptMultiple: { id: 'notification_requests.accept_multiple', defaultMessage: '{count, plural, one {Accept # request} other {Accept # requests}}' },
dismissMultiple: { id: 'notification_requests.dismiss_multiple', defaultMessage: '{count, plural, one {Dismiss # request} other {Dismiss # requests}}' },
confirmAcceptAllTitle: { id: 'notification_requests.confirm_accept_all.title', defaultMessage: 'Accept notification requests?' },
confirmAcceptAllMessage: { id: 'notification_requests.confirm_accept_all.message', defaultMessage: 'You are about to accept {count, plural, one {one notification request} other {# notification requests}}. Are you sure you want to proceed?' },
confirmAcceptAllButton: { id: 'notification_requests.confirm_accept_all.button', defaultMessage: 'Accept all' },
confirmDismissAllTitle: { id: 'notification_requests.confirm_dismiss_all.title', defaultMessage: 'Dismiss notification requests?' },
confirmDismissAllMessage: { id: 'notification_requests.confirm_dismiss_all.message', defaultMessage: "You are about to dismiss {count, plural, one {one notification request} other {# notification requests}}. You won't be able to easily access {count, plural, one {it} other {them}} again. Are you sure you want to proceed?" },
confirmDismissAllButton: { id: 'notification_requests.confirm_dismiss_all.button', defaultMessage: 'Dismiss all' },
}); });
const ColumnSettings = () => { const ColumnSettings = () => {
@ -55,6 +70,94 @@ const ColumnSettings = () => {
); );
}; };
const SelectRow = ({selectAllChecked, toggleSelectAll, selectedItems, selectionMode, setSelectionMode}) => {
const intl = useIntl();
const dispatch = useDispatch();
const selectedCount = selectedItems.length;
const handleAcceptAll = useCallback(() => {
dispatch(openModal({
modalType: 'CONFIRM',
modalProps: {
title: intl.formatMessage(messages.confirmAcceptAllTitle),
message: intl.formatMessage(messages.confirmAcceptAllMessage, { count: selectedItems.length }),
confirm: intl.formatMessage(messages.confirmAcceptAllButton),
onConfirm: () =>
dispatch(acceptNotificationRequests(selectedItems)),
},
}));
}, [dispatch, intl, selectedItems]);
const handleDismissAll = useCallback(() => {
dispatch(openModal({
modalType: 'CONFIRM',
modalProps: {
title: intl.formatMessage(messages.confirmDismissAllTitle),
message: intl.formatMessage(messages.confirmDismissAllMessage, { count: selectedItems.length }),
confirm: intl.formatMessage(messages.confirmDismissAllButton),
onConfirm: () =>
dispatch(dismissNotificationRequests(selectedItems)),
},
}));
}, [dispatch, intl, selectedItems]);
const handleToggleSelectionMode = useCallback(() => {
setSelectionMode((mode) => !mode);
}, [setSelectionMode]);
const menu = selectedCount === 0 ?
[
{ text: intl.formatMessage(messages.acceptAll), action: handleAcceptAll },
{ text: intl.formatMessage(messages.dismissAll), action: handleDismissAll },
] : [
{ text: intl.formatMessage(messages.acceptMultiple, { count: selectedCount }), action: handleAcceptAll },
{ text: intl.formatMessage(messages.dismissMultiple, { count: selectedCount }), action: handleDismissAll },
];
return (
<div className='column-header__select-row'>
{selectionMode && (
<div className='column-header__select-row__checkbox'>
<CheckBox checked={selectAllChecked} indeterminate={selectedCount > 0 && !selectAllChecked} onChange={toggleSelectAll} />
</div>
)}
<div className='column-header__select-row__selection-mode'>
<button className='text-btn' tabIndex={0} onClick={handleToggleSelectionMode}>
{selectionMode ? (
<FormattedMessage id='notification_requests.exit_selection_mode' defaultMessage='Cancel' />
) :
(
<FormattedMessage id='notification_requests.enter_selection_mode' defaultMessage='Select' />
)}
</button>
</div>
{selectedCount > 0 &&
<div className='column-header__select-row__selected-count'>
{selectedCount} selected
</div>
}
<div className='column-header__select-row__actions'>
<DropdownMenuContainer
items={menu}
icons='ellipsis-h'
iconComponent={MoreHorizIcon}
direction='right'
title={intl.formatMessage(messages.more)}
/>
</div>
</div>
);
};
SelectRow.propTypes = {
selectAllChecked: PropTypes.func.isRequired,
toggleSelectAll: PropTypes.func.isRequired,
selectedItems: PropTypes.arrayOf(PropTypes.string).isRequired,
selectionMode: PropTypes.bool,
setSelectionMode: PropTypes.func.isRequired,
};
export const NotificationRequests = ({ multiColumn }) => { export const NotificationRequests = ({ multiColumn }) => {
const columnRef = useRef(); const columnRef = useRef();
const intl = useIntl(); const intl = useIntl();
@ -63,10 +166,40 @@ export const NotificationRequests = ({ multiColumn }) => {
const notificationRequests = useSelector(state => state.getIn(['notificationRequests', 'items'])); const notificationRequests = useSelector(state => state.getIn(['notificationRequests', 'items']));
const hasMore = useSelector(state => !!state.getIn(['notificationRequests', 'next'])); const hasMore = useSelector(state => !!state.getIn(['notificationRequests', 'next']));
const [selectionMode, setSelectionMode] = useState(false);
const [checkedRequestIds, setCheckedRequestIds] = useState([]);
const [selectAllChecked, setSelectAllChecked] = useState(false);
const handleHeaderClick = useCallback(() => { const handleHeaderClick = useCallback(() => {
columnRef.current?.scrollTop(); columnRef.current?.scrollTop();
}, [columnRef]); }, [columnRef]);
const handleCheck = useCallback(id => {
setCheckedRequestIds(ids => {
const position = ids.indexOf(id);
if(position > -1)
ids.splice(position, 1);
else
ids.push(id);
setSelectAllChecked(ids.length === notificationRequests.size);
return [...ids];
});
}, [setCheckedRequestIds, notificationRequests]);
const toggleSelectAll = useCallback(() => {
setSelectAllChecked(checked => {
if(checked)
setCheckedRequestIds([]);
else
setCheckedRequestIds(notificationRequests.map(request => request.get('id')).toArray());
return !checked;
});
}, [notificationRequests]);
const handleLoadMore = useCallback(() => { const handleLoadMore = useCallback(() => {
dispatch(expandNotificationRequests()); dispatch(expandNotificationRequests());
}, [dispatch]); }, [dispatch]);
@ -84,6 +217,8 @@ export const NotificationRequests = ({ multiColumn }) => {
onClick={handleHeaderClick} onClick={handleHeaderClick}
multiColumn={multiColumn} multiColumn={multiColumn}
showBackButton showBackButton
appendContent={
<SelectRow selectionMode={selectionMode} setSelectionMode={setSelectionMode} selectAllChecked={selectAllChecked} toggleSelectAll={toggleSelectAll} selectedItems={checkedRequestIds} />}
> >
<ColumnSettings /> <ColumnSettings />
</ColumnHeader> </ColumnHeader>
@ -104,6 +239,9 @@ export const NotificationRequests = ({ multiColumn }) => {
id={request.get('id')} id={request.get('id')}
accountId={request.get('account')} accountId={request.get('account')}
notificationsCount={request.get('notifications_count')} notificationsCount={request.get('notifications_count')}
showCheckbox={selectionMode}
checked={checkedRequestIds.includes(request.get('id'))}
toggleCheck={handleCheck}
/> />
))} ))}
</ScrollableList> </ScrollableList>

View File

@ -2,26 +2,33 @@ import { FormattedMessage } from 'react-intl';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react'; import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import ReplyIcon from '@/material-icons/400-24px/reply-fill.svg?react'; import ReplyIcon from '@/material-icons/400-24px/reply-fill.svg?react';
import type { StatusVisibility } from 'mastodon/api_types/statuses'; import { me } from 'mastodon/initial_state';
import type { NotificationGroupMention } from 'mastodon/models/notification_group'; import type { NotificationGroupMention } from 'mastodon/models/notification_group';
import type { Status } from 'mastodon/models/status';
import { useAppSelector } from 'mastodon/store'; import { useAppSelector } from 'mastodon/store';
import type { LabelRenderer } from './notification_group_with_status'; import type { LabelRenderer } from './notification_group_with_status';
import { NotificationWithStatus } from './notification_with_status'; import { NotificationWithStatus } from './notification_with_status';
const labelRenderer: LabelRenderer = (values) => ( const mentionLabelRenderer: LabelRenderer = () => (
<FormattedMessage id='notification.label.mention' defaultMessage='Mention' />
);
const privateMentionLabelRenderer: LabelRenderer = () => (
<FormattedMessage <FormattedMessage
id='notification.mention' id='notification.label.private_mention'
defaultMessage='{name} mentioned you' defaultMessage='Private mention'
values={values}
/> />
); );
const privateMentionLabelRenderer: LabelRenderer = (values) => ( const replyLabelRenderer: LabelRenderer = () => (
<FormattedMessage id='notification.label.reply' defaultMessage='Reply' />
);
const privateReplyLabelRenderer: LabelRenderer = () => (
<FormattedMessage <FormattedMessage
id='notification.private_mention' id='notification.label.private_reply'
defaultMessage='{name} privately mentioned you' defaultMessage='Private reply'
values={values}
/> />
); );
@ -29,27 +36,30 @@ export const NotificationMention: React.FC<{
notification: NotificationGroupMention; notification: NotificationGroupMention;
unread: boolean; unread: boolean;
}> = ({ notification, unread }) => { }> = ({ notification, unread }) => {
const statusVisibility = useAppSelector( const [isDirect, isReply] = useAppSelector((state) => {
(state) => const status = state.statuses.get(notification.statusId) as Status;
state.statuses.getIn([
notification.statusId, return [
'visibility', status.get('visibility') === 'direct',
]) as StatusVisibility, status.get('in_reply_to_account_id') === me,
); ] as const;
});
let labelRenderer = mentionLabelRenderer;
if (isReply && isDirect) labelRenderer = privateReplyLabelRenderer;
else if (isReply) labelRenderer = replyLabelRenderer;
else if (isDirect) labelRenderer = privateMentionLabelRenderer;
return ( return (
<NotificationWithStatus <NotificationWithStatus
type='mention' type='mention'
icon={statusVisibility === 'direct' ? AlternateEmailIcon : ReplyIcon} icon={isReply ? ReplyIcon : AlternateEmailIcon}
iconId='reply' iconId='reply'
accountIds={notification.sampleAccountIds} accountIds={notification.sampleAccountIds}
count={notification.notifications_count} count={notification.notifications_count}
statusId={notification.statusId} statusId={notification.statusId}
labelRenderer={ labelRenderer={labelRenderer}
statusVisibility === 'direct'
? privateMentionLabelRenderer
: labelRenderer
}
unread={unread} unread={unread}
/> />
); );

View File

@ -4,8 +4,6 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { createSelector } from '@reduxjs/toolkit';
import { useDebouncedCallback } from 'use-debounce'; import { useDebouncedCallback } from 'use-debounce';
import DoneAllIcon from '@/material-icons/400-24px/done_all.svg?react'; import DoneAllIcon from '@/material-icons/400-24px/done_all.svg?react';
@ -27,16 +25,13 @@ import {
selectUnreadNotificationGroupsCount, selectUnreadNotificationGroupsCount,
selectPendingNotificationGroupsCount, selectPendingNotificationGroupsCount,
selectAnyPendingNotification, selectAnyPendingNotification,
selectNotificationGroups,
} from 'mastodon/selectors/notifications'; } from 'mastodon/selectors/notifications';
import { import {
selectNeedsNotificationPermission, selectNeedsNotificationPermission,
selectSettingsNotificationsExcludedTypes,
selectSettingsNotificationsQuickFilterActive,
selectSettingsNotificationsQuickFilterShow,
selectSettingsNotificationsShowUnread, selectSettingsNotificationsShowUnread,
} from 'mastodon/selectors/settings'; } from 'mastodon/selectors/settings';
import { useAppDispatch, useAppSelector } from 'mastodon/store'; import { useAppDispatch, useAppSelector } from 'mastodon/store';
import type { RootState } from 'mastodon/store';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import { submitMarkers } from '../../actions/markers'; import { submitMarkers } from '../../actions/markers';
@ -62,34 +57,12 @@ const messages = defineMessages({
}, },
}); });
const getNotifications = createSelector(
[
selectSettingsNotificationsQuickFilterShow,
selectSettingsNotificationsQuickFilterActive,
selectSettingsNotificationsExcludedTypes,
(state: RootState) => state.notificationGroups.groups,
],
(showFilterBar, allowedType, excludedTypes, notifications) => {
if (!showFilterBar || allowedType === 'all') {
// used if user changed the notification settings after loading the notifications from the server
// otherwise a list of notifications will come pre-filtered from the backend
// we need to turn it off for FilterBar in order not to block ourselves from seeing a specific category
return notifications.filter(
(item) => item.type === 'gap' || !excludedTypes.includes(item.type),
);
}
return notifications.filter(
(item) => item.type === 'gap' || allowedType === item.type,
);
},
);
export const Notifications: React.FC<{ export const Notifications: React.FC<{
columnId?: string; columnId?: string;
multiColumn?: boolean; multiColumn?: boolean;
}> = ({ columnId, multiColumn }) => { }> = ({ columnId, multiColumn }) => {
const intl = useIntl(); const intl = useIntl();
const notifications = useAppSelector(getNotifications); const notifications = useAppSelector(selectNotificationGroups);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const isLoading = useAppSelector((s) => s.notificationGroups.isLoading); const isLoading = useAppSelector((s) => s.notificationGroups.isLoading);
const hasMore = notifications.at(-1)?.type === 'gap'; const hasMore = notifications.at(-1)?.type === 'gap';

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 '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 = <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

@ -17,6 +17,7 @@ import {
InteractionModal, InteractionModal,
SubscribedLanguagesModal, SubscribedLanguagesModal,
ClosedRegistrationsModal, ClosedRegistrationsModal,
IgnoreNotificationsModal,
} from 'mastodon/features/ui/util/async-components'; } from 'mastodon/features/ui/util/async-components';
import { getScrollbarWidth } from 'mastodon/utils/scrollbar'; import { getScrollbarWidth } from 'mastodon/utils/scrollbar';
@ -70,6 +71,7 @@ export const MODAL_COMPONENTS = {
'SUBSCRIBED_LANGUAGES': SubscribedLanguagesModal, 'SUBSCRIBED_LANGUAGES': SubscribedLanguagesModal,
'INTERACTION': InteractionModal, 'INTERACTION': InteractionModal,
'CLOSED_REGISTRATIONS': ClosedRegistrationsModal, 'CLOSED_REGISTRATIONS': ClosedRegistrationsModal,
'IGNORE_NOTIFICATIONS': IgnoreNotificationsModal,
}; };
export default class ModalRoot extends PureComponent { export default class ModalRoot extends PureComponent {

View File

@ -134,6 +134,10 @@ export function ReportModal () {
return import(/* webpackChunkName: "modals/report_modal" */'../components/report_modal'); 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 () { export function MediaGallery () {
return import(/* webpackChunkName: "status/media_gallery" */'../../../components/media_gallery'); return import(/* webpackChunkName: "status/media_gallery" */'../../../components/media_gallery');
} }

View File

@ -356,6 +356,17 @@
"home.pending_critical_update.link": "See updates", "home.pending_critical_update.link": "See updates",
"home.pending_critical_update.title": "Critical security update available!", "home.pending_critical_update.title": "Critical security update available!",
"home.show_announcements": "Show announcements", "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.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.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.", "interaction_modal.description.reblog": "With an account on Mastodon, you can boost this post to share it with your own followers.",
@ -482,7 +493,11 @@
"notification.favourite": "{name} favorited your post", "notification.favourite": "{name} favorited your post",
"notification.follow": "{name} followed you", "notification.follow": "{name} followed you",
"notification.follow_request": "{name} has requested to follow you", "notification.follow_request": "{name} has requested to follow you",
"notification.mention": "{name} mentioned you", "notification.label.mention": "Mention",
"notification.label.private_mention": "Private mention",
"notification.label.private_reply": "Private reply",
"notification.label.reply": "Reply",
"notification.mention": "Mention",
"notification.moderation-warning.learn_more": "Learn more", "notification.moderation-warning.learn_more": "Learn more",
"notification.moderation_warning": "You have received a moderation warning", "notification.moderation_warning": "You have received a moderation warning",
"notification.moderation_warning.action_delete_statuses": "Some of your posts have been removed.", "notification.moderation_warning.action_delete_statuses": "Some of your posts have been removed.",
@ -494,7 +509,6 @@
"notification.moderation_warning.action_suspend": "Your account has been suspended.", "notification.moderation_warning.action_suspend": "Your account has been suspended.",
"notification.own_poll": "Your poll has ended", "notification.own_poll": "Your poll has ended",
"notification.poll": "A poll you voted in has ended", "notification.poll": "A poll you voted in has ended",
"notification.private_mention": "{name} privately mentioned you",
"notification.reblog": "{name} boosted your post", "notification.reblog": "{name} boosted your post",
"notification.relationships_severance_event": "Lost connections with {name}", "notification.relationships_severance_event": "Lost connections with {name}",
"notification.relationships_severance_event.account_suspension": "An admin from {from} has suspended {target}, which means you can no longer receive updates from them or interact with them.", "notification.relationships_severance_event.account_suspension": "An admin from {from} has suspended {target}, which means you can no longer receive updates from them or interact with them.",
@ -504,13 +518,26 @@
"notification.status": "{name} just posted", "notification.status": "{name} just posted",
"notification.update": "{name} edited a post", "notification.update": "{name} edited a post",
"notification_requests.accept": "Accept", "notification_requests.accept": "Accept",
"notification_requests.accept_all": "Accept all",
"notification_requests.accept_multiple": "{count, plural, one {Accept # request} other {Accept # requests}}",
"notification_requests.confirm_accept_all.button": "Accept all",
"notification_requests.confirm_accept_all.message": "You are about to accept {count, plural, one {one notification request} other {# notification requests}}. Are you sure you want to proceed?",
"notification_requests.confirm_accept_all.title": "Accept notification requests?",
"notification_requests.confirm_dismiss_all.button": "Dismiss all",
"notification_requests.confirm_dismiss_all.message": "You are about to dismiss {count, plural, one {one notification request} other {# notification requests}}. You won't be able to easily access {count, plural, one {it} other {them}} again. Are you sure you want to proceed?",
"notification_requests.confirm_dismiss_all.title": "Dismiss notification requests?",
"notification_requests.dismiss": "Dismiss", "notification_requests.dismiss": "Dismiss",
"notification_requests.dismiss_all": "Dismiss all",
"notification_requests.dismiss_multiple": "{count, plural, one {Dismiss # request} other {Dismiss # requests}}",
"notification_requests.enter_selection_mode": "Select",
"notification_requests.exit_selection_mode": "Cancel",
"notification_requests.explainer_for_limited_account": "Notifications from this account have been filtered because the account has been limited by a moderator.", "notification_requests.explainer_for_limited_account": "Notifications from this account have been filtered because the account has been limited by a moderator.",
"notification_requests.explainer_for_limited_remote_account": "Notifications from this account have been filtered because the account or its server has been limited by a moderator.", "notification_requests.explainer_for_limited_remote_account": "Notifications from this account have been filtered because the account or its server has been limited by a moderator.",
"notification_requests.maximize": "Maximize", "notification_requests.maximize": "Maximize",
"notification_requests.minimize_banner": "Minimize filtered notifications banner", "notification_requests.minimize_banner": "Minimize filtered notifications banner",
"notification_requests.notifications_from": "Notifications from {name}", "notification_requests.notifications_from": "Notifications from {name}",
"notification_requests.title": "Filtered notifications", "notification_requests.title": "Filtered notifications",
"notification_requests.view": "View notifications",
"notifications.clear": "Clear notifications", "notifications.clear": "Clear notifications",
"notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?", "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?",
"notifications.clear_title": "Clear notifications?", "notifications.clear_title": "Clear notifications?",
@ -547,6 +574,12 @@
"notifications.permission_denied": "Desktop notifications are unavailable due to previously denied browser permissions request", "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_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.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_hint": "Limited by server moderators",
"notifications.policy.filter_limited_accounts_title": "Moderated accounts", "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}}", "notifications.policy.filter_new_accounts.hint": "Created within the past {days, plural, one {one day} other {# days}}",
@ -557,7 +590,7 @@
"notifications.policy.filter_not_following_title": "People you don't follow", "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_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.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.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.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", "notifications_permission_banner.title": "Never miss a thing",

View File

@ -13,6 +13,8 @@ import {
NOTIFICATION_REQUEST_FETCH_FAIL, NOTIFICATION_REQUEST_FETCH_FAIL,
NOTIFICATION_REQUEST_ACCEPT_REQUEST, NOTIFICATION_REQUEST_ACCEPT_REQUEST,
NOTIFICATION_REQUEST_DISMISS_REQUEST, NOTIFICATION_REQUEST_DISMISS_REQUEST,
NOTIFICATION_REQUESTS_ACCEPT_REQUEST,
NOTIFICATION_REQUESTS_DISMISS_REQUEST,
NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST, NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST,
NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS, NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS,
NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL, NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL,
@ -83,6 +85,9 @@ export const notificationRequestsReducer = (state = initialState, action) => {
case NOTIFICATION_REQUEST_ACCEPT_REQUEST: case NOTIFICATION_REQUEST_ACCEPT_REQUEST:
case NOTIFICATION_REQUEST_DISMISS_REQUEST: case NOTIFICATION_REQUEST_DISMISS_REQUEST:
return removeRequest(state, action.id); return removeRequest(state, action.id);
case NOTIFICATION_REQUESTS_ACCEPT_REQUEST:
case NOTIFICATION_REQUESTS_DISMISS_REQUEST:
return action.ids.reduce((state, id) => removeRequest(state, id), state);
case blockAccountSuccess.type: case blockAccountSuccess.type:
return removeRequestByAccount(state, action.payload.relationship.id); return removeRequestByAccount(state, action.payload.relationship.id);
case muteAccountSuccess.type: case muteAccountSuccess.type:

View File

@ -1,15 +1,62 @@
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import { compareId } from 'mastodon/compare_id'; import { compareId } from 'mastodon/compare_id';
import type { NotificationGroup } from 'mastodon/models/notification_group';
import type { NotificationGap } from 'mastodon/reducers/notification_groups';
import type { RootState } from 'mastodon/store'; import type { RootState } from 'mastodon/store';
import {
selectSettingsNotificationsExcludedTypes,
selectSettingsNotificationsQuickFilterActive,
selectSettingsNotificationsQuickFilterShow,
} from './settings';
const filterNotificationsByAllowedTypes = (
showFilterBar: boolean,
allowedType: string,
excludedTypes: string[],
notifications: (NotificationGroup | NotificationGap)[],
) => {
if (!showFilterBar || allowedType === 'all') {
// used if user changed the notification settings after loading the notifications from the server
// otherwise a list of notifications will come pre-filtered from the backend
// we need to turn it off for FilterBar in order not to block ourselves from seeing a specific category
return notifications.filter(
(item) => item.type === 'gap' || !excludedTypes.includes(item.type),
);
}
return notifications.filter(
(item) => item.type === 'gap' || allowedType === item.type,
);
};
export const selectNotificationGroups = createSelector(
[
selectSettingsNotificationsQuickFilterShow,
selectSettingsNotificationsQuickFilterActive,
selectSettingsNotificationsExcludedTypes,
(state: RootState) => state.notificationGroups.groups,
],
filterNotificationsByAllowedTypes,
);
const selectPendingNotificationGroups = createSelector(
[
selectSettingsNotificationsQuickFilterShow,
selectSettingsNotificationsQuickFilterActive,
selectSettingsNotificationsExcludedTypes,
(state: RootState) => state.notificationGroups.pendingGroups,
],
filterNotificationsByAllowedTypes,
);
export const selectUnreadNotificationGroupsCount = createSelector( export const selectUnreadNotificationGroupsCount = createSelector(
[ [
(s: RootState) => s.notificationGroups.lastReadId, (s: RootState) => s.notificationGroups.lastReadId,
(s: RootState) => s.notificationGroups.pendingGroups, selectNotificationGroups,
(s: RootState) => s.notificationGroups.groups, selectPendingNotificationGroups,
], ],
(notificationMarker, pendingGroups, groups) => { (notificationMarker, groups, pendingGroups) => {
return ( return (
groups.filter( groups.filter(
(group) => (group) =>
@ -31,7 +78,7 @@ export const selectUnreadNotificationGroupsCount = createSelector(
export const selectAnyPendingNotification = createSelector( export const selectAnyPendingNotification = createSelector(
[ [
(s: RootState) => s.notificationGroups.readMarkerId, (s: RootState) => s.notificationGroups.readMarkerId,
(s: RootState) => s.notificationGroups.groups, selectNotificationGroups,
], ],
(notificationMarker, groups) => { (notificationMarker, groups) => {
return groups.some( return groups.some(
@ -44,7 +91,7 @@ export const selectAnyPendingNotification = createSelector(
); );
export const selectPendingNotificationGroupsCount = createSelector( export const selectPendingNotificationGroupsCount = createSelector(
[(s: RootState) => s.notificationGroups.pendingGroups], [selectPendingNotificationGroups],
(pendingGroups) => (pendingGroups) =>
pendingGroups.filter((group) => group.type !== 'gap').length, pendingGroups.filter((group) => group.type !== 'gap').length,
); );

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M240-440v-80h480v80H240Z"/></svg>

After

Width:  |  Height:  |  Size: 130 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M240-440v-80h480v80H240Z"/></svg>

After

Width:  |  Height:  |  Size: 130 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M800-520q-17 0-28.5-11.5T760-560q0-17 11.5-28.5T800-600q17 0 28.5 11.5T840-560q0 17-11.5 28.5T800-520Zm-40-120v-200h80v200h-80ZM360-480q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 66-47 113t-113 47ZM40-160v-112q0-34 17.5-62.5T104-378q62-31 126-46.5T360-440q66 0 130 15.5T616-378q29 15 46.5 43.5T680-272v112H40Z"/></svg>

After

Width:  |  Height:  |  Size: 433 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M800-520q-17 0-28.5-11.5T760-560q0-17 11.5-28.5T800-600q17 0 28.5 11.5T840-560q0 17-11.5 28.5T800-520Zm-40-120v-200h80v200h-80ZM360-480q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 66-47 113t-113 47ZM40-160v-112q0-34 17.5-62.5T104-378q62-31 126-46.5T360-440q66 0 130 15.5T616-378q29 15 46.5 43.5T680-272v112H40Zm80-80h480v-32q0-11-5.5-20T580-306q-54-27-109-40.5T360-360q-56 0-111 13.5T140-306q-9 5-14.5 14t-5.5 20v32Zm240-320q33 0 56.5-23.5T440-640q0-33-23.5-56.5T360-720q-33 0-56.5 23.5T280-640q0 33 23.5 56.5T360-560Zm0-80Zm0 400Z"/></svg>

After

Width:  |  Height:  |  Size: 654 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M480-80q-139-35-229.5-159.5T160-516v-244l320-120 320 120v244q0 152-90.5 276.5T480-80Zm0-200q17 0 29.5-12.5T522-322q0-17-12.5-29.5T480-364q-17 0-29.5 12.5T438-322q0 17 12.5 29.5T480-280Zm-29-128h60v-22q0-11 5-21 6-14 16-23.5t21-19.5q17-17 29.5-38t12.5-46q0-45-34.5-73.5T480-680q-40 0-71.5 23T366-596l54 22q6-20 22.5-34t37.5-14q22 0 38.5 13t16.5 33q0 17-10.5 31.5T501-518q-12 11-24 22.5T458-469q-7 14-7 29.5v31.5Z"/></svg>

After

Width:  |  Height:  |  Size: 517 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M480-80q-139-35-229.5-159.5T160-516v-244l320-120 320 120v244q0 152-90.5 276.5T480-80Zm0-84q104-33 172-132t68-220v-189l-240-90-240 90v189q0 121 68 220t172 132Zm0-316Zm0 200q17 0 29.5-12.5T522-322q0-17-12.5-29.5T480-364q-17 0-29.5 12.5T438-322q0 17 12.5 29.5T480-280Zm-29-128h60v-22q0-11 5-21 6-14 16-23.5t21-19.5q17-17 29.5-38t12.5-46q0-45-34.5-73.5T480-680q-40 0-71.5 23T366-596l54 22q6-20 22.5-34t37.5-14q22 0 38.5 13t16.5 33q0 17-10.5 31.5T501-518q-12 11-24 22.5T458-469q-7 14-7 29.5v31.5Z"/></svg>

After

Width:  |  Height:  |  Size: 597 B

View File

@ -214,12 +214,6 @@ html {
border-top-color: lighten($ui-base-color, 8%); border-top-color: lighten($ui-base-color, 8%);
} }
.column-header__collapsible-inner {
background: darken($ui-base-color, 4%);
border: 1px solid var(--background-border-color);
border-bottom: 0;
}
.column-settings__hashtags .column-select__option { .column-settings__hashtags .column-select__option {
color: $white; color: $white;
} }
@ -557,3 +551,11 @@ a.sparkline {
background: darken($ui-base-color, 10%); background: darken($ui-base-color, 10%);
} }
} }
.setting-text {
background: darken($ui-base-color, 10%);
}
.report-dialog-modal__textarea {
background: darken($ui-base-color, 10%);
}

View File

@ -21,7 +21,7 @@ $valid-value-color: $success-green !default;
$ui-base-color: $classic-secondary-color !default; $ui-base-color: $classic-secondary-color !default;
$ui-base-lighter-color: #b0c0cf; $ui-base-lighter-color: #b0c0cf;
$ui-primary-color: #9bcbed; $ui-primary-color: $classic-primary-color !default;
$ui-secondary-color: $classic-base-color !default; $ui-secondary-color: $classic-base-color !default;
$ui-highlight-color: $classic-highlight-color !default; $ui-highlight-color: $classic-highlight-color !default;

View File

@ -877,6 +877,13 @@ body > [data-popper-placement] {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
&[disabled] {
cursor: default;
color: $highlight-text-color;
border-color: $highlight-text-color;
opacity: 0.5;
}
.icon { .icon {
width: 15px; width: 15px;
height: 15px; height: 15px;
@ -2779,6 +2786,11 @@ $ui-header-logo-wordmark-width: 99px;
&.privacy-policy { &.privacy-policy {
border-top: 1px solid var(--background-border-color); border-top: 1px solid var(--background-border-color);
border-radius: 4px; border-radius: 4px;
@media screen and (max-width: $no-gap-breakpoint) {
border-top: 0;
border-bottom: 0;
}
} }
} }
} }
@ -3876,18 +3888,17 @@ $ui-header-logo-wordmark-width: 99px;
display: block; display: block;
box-sizing: border-box; box-sizing: border-box;
margin: 0; margin: 0;
color: $inverted-text-color; color: $primary-text-color;
background: $white; background: $ui-base-color;
padding: 7px 10px; padding: 7px 10px;
font-family: inherit; font-family: inherit;
font-size: 14px; font-size: 14px;
line-height: 22px; line-height: 22px;
border-radius: 4px; border-radius: 4px;
border: 1px solid $white; border: 1px solid var(--background-border-color);
&:focus { &:focus {
outline: 0; outline: 0;
border-color: lighten($ui-highlight-color, 12%);
} }
&__wrapper { &__wrapper {
@ -4309,6 +4320,36 @@ a.status-card {
} }
} }
.column-header__select-row {
border-width: 0 1px 1px;
border-style: solid;
border-color: var(--background-border-color);
padding: 15px;
display: flex;
align-items: center;
gap: 8px;
&__checkbox .check-box {
display: flex;
}
&__selection-mode {
flex-grow: 1;
.text-btn:hover {
text-decoration: underline;
}
}
&__actions {
.icon-button {
border-radius: 4px;
border: 1px solid var(--background-border-color);
padding: 5px;
}
}
}
.column-header { .column-header {
display: flex; display: flex;
font-size: 16px; font-size: 16px;
@ -4472,6 +4513,11 @@ a.status-card {
.column-header__collapsible-inner { .column-header__collapsible-inner {
border: 1px solid var(--background-border-color); border: 1px solid var(--background-border-color);
border-top: 0; border-top: 0;
@media screen and (max-width: $no-gap-breakpoint) {
border-left: 0;
border-right: 0;
}
} }
.column-header__setting-btn { .column-header__setting-btn {
@ -6235,9 +6281,10 @@ a.status-card {
max-width: 90vw; max-width: 90vw;
width: 480px; width: 480px;
height: 80vh; height: 80vh;
background: lighten($ui-secondary-color, 8%); background: var(--background-color);
color: $inverted-text-color; color: $primary-text-color;
border-radius: 8px; border-radius: 4px;
border: 1px solid var(--background-border-color);
overflow: hidden; overflow: hidden;
position: relative; position: relative;
flex-direction: column; flex-direction: column;
@ -6245,7 +6292,7 @@ a.status-card {
&__container { &__container {
box-sizing: border-box; box-sizing: border-box;
border-top: 1px solid $ui-secondary-color; border-top: 1px solid var(--background-border-color);
padding: 20px; padding: 20px;
flex-grow: 1; flex-grow: 1;
display: flex; display: flex;
@ -6275,7 +6322,7 @@ a.status-card {
&__lead { &__lead {
font-size: 17px; font-size: 17px;
line-height: 22px; line-height: 22px;
color: lighten($inverted-text-color, 16%); color: $secondary-text-color;
margin-bottom: 30px; margin-bottom: 30px;
a { a {
@ -6310,7 +6357,7 @@ a.status-card {
.status__content, .status__content,
.status__content p { .status__content p {
color: $inverted-text-color; color: $primary-text-color;
} }
.status__content__spoiler-link { .status__content__spoiler-link {
@ -6355,7 +6402,7 @@ a.status-card {
.poll__option.dialog-option { .poll__option.dialog-option {
padding: 15px 0; padding: 15px 0;
flex: 0 0 auto; flex: 0 0 auto;
border-bottom: 1px solid $ui-secondary-color; border-bottom: 1px solid var(--background-border-color);
&:last-child { &:last-child {
border-bottom: 0; border-bottom: 0;
@ -6363,13 +6410,13 @@ a.status-card {
& > .poll__option__text { & > .poll__option__text {
font-size: 13px; font-size: 13px;
color: lighten($inverted-text-color, 16%); color: $secondary-text-color;
strong { strong {
font-size: 17px; font-size: 17px;
font-weight: 500; font-weight: 500;
line-height: 22px; line-height: 22px;
color: $inverted-text-color; color: $primary-text-color;
display: block; display: block;
margin-bottom: 4px; margin-bottom: 4px;
@ -6388,22 +6435,19 @@ a.status-card {
display: block; display: block;
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
color: $inverted-text-color; color: $primary-text-color;
background: $simple-background-color; background: $ui-base-color;
padding: 10px; padding: 10px;
font-family: inherit; font-family: inherit;
font-size: 17px; font-size: 17px;
line-height: 22px; line-height: 22px;
resize: vertical; resize: vertical;
border: 0; border: 0;
border: 1px solid var(--background-border-color);
outline: 0; outline: 0;
border-radius: 4px; border-radius: 4px;
margin: 20px 0; margin: 20px 0;
&::placeholder {
color: $dark-text-color;
}
&:focus { &:focus {
outline: 0; outline: 0;
} }
@ -6424,16 +6468,16 @@ a.status-card {
} }
.button.button-secondary { .button.button-secondary {
border-color: $inverted-text-color; border-color: $ui-button-destructive-background-color;
color: $inverted-text-color; color: $ui-button-destructive-background-color;
flex: 0 0 auto; flex: 0 0 auto;
&:hover, &:hover,
&:focus, &:focus,
&:active { &:active {
background: transparent; background: $ui-button-destructive-background-color;
border-color: $ui-button-background-color; border-color: $ui-button-destructive-background-color;
color: $ui-button-background-color; color: $white;
} }
} }
@ -7453,20 +7497,9 @@ a.status-card {
flex: 0 0 auto; flex: 0 0 auto;
border-radius: 50%; border-radius: 50%;
&.checked { &.checked,
&.indeterminate {
border-color: $ui-highlight-color; border-color: $ui-highlight-color;
&::before {
position: absolute;
left: 2px;
top: 2px;
content: '';
display: block;
border-radius: 50%;
width: 12px;
height: 12px;
background: $ui-highlight-color;
}
} }
.icon { .icon {
@ -7476,19 +7509,28 @@ a.status-card {
} }
} }
.radio-button.checked::before {
position: absolute;
left: 2px;
top: 2px;
content: '';
display: block;
border-radius: 50%;
width: 12px;
height: 12px;
background: $ui-highlight-color;
}
.check-box { .check-box {
&__input { &__input {
width: 18px; width: 18px;
height: 18px; height: 18px;
border-radius: 2px; border-radius: 2px;
&.checked { &.checked,
&.indeterminate {
background: $ui-highlight-color; background: $ui-highlight-color;
color: $white; color: $white;
&::before {
display: none;
}
} }
} }
} }
@ -7657,6 +7699,11 @@ noscript {
width: 100%; width: 100%;
} }
} }
@media screen and (max-width: $no-gap-breakpoint) {
border-left: 0;
border-right: 0;
}
} }
.drawer__backdrop { .drawer__backdrop {
@ -10204,12 +10251,28 @@ noscript {
} }
.notification-request { .notification-request {
$padding: 15px;
display: flex; display: flex;
align-items: center; padding: $padding;
gap: 16px; gap: 8px;
padding: 15px; position: relative;
border-bottom: 1px solid var(--background-border-color); border-bottom: 1px solid var(--background-border-color);
&__checkbox {
position: absolute;
inset-inline-start: $padding;
top: 50%;
transform: translateY(-50%);
width: 0;
overflow: hidden;
opacity: 0;
.check-box {
display: flex;
}
}
&__link { &__link {
display: flex; display: flex;
align-items: center; align-items: center;
@ -10267,6 +10330,31 @@ noscript {
padding: 5px; padding: 5px;
} }
} }
.notification-request__link {
transition: padding-inline-start 0.1s ease-in-out;
}
&--forced-checkbox {
cursor: pointer;
&:hover {
background: lighten($ui-base-color, 1%);
}
.notification-request__checkbox {
opacity: 1;
width: 30px;
}
.notification-request__link {
padding-inline-start: 30px;
}
.notification-request__actions {
display: none;
}
}
} }
.more-from-author { .more-from-author {

View File

@ -83,11 +83,6 @@
max-height: 35vh; max-height: 35vh;
padding: 0 6px 6px; padding: 0 6px 6px;
will-change: transform; will-change: transform;
&::-webkit-scrollbar-track:hover,
&::-webkit-scrollbar-track:active {
background-color: rgba($base-overlay-background, 0.3);
}
} }
.emoji-mart-search { .emoji-mart-search {
@ -116,7 +111,6 @@
&:focus { &:focus {
outline: none !important; outline: none !important;
border-width: 1px !important; border-width: 1px !important;
border-color: $ui-button-background-color;
} }
&::-webkit-search-cancel-button { &::-webkit-search-cancel-button {

View File

@ -56,40 +56,3 @@ table {
html { html {
scrollbar-color: lighten($ui-base-color, 4%) rgba($base-overlay-background, 0.1); scrollbar-color: lighten($ui-base-color, 4%) rgba($base-overlay-background, 0.1);
} }
::-webkit-scrollbar {
width: 12px;
height: 12px;
}
::-webkit-scrollbar-thumb {
background: lighten($ui-base-color, 4%);
border: 0px none $base-border-color;
border-radius: 50px;
}
::-webkit-scrollbar-thumb:hover {
background: lighten($ui-base-color, 6%);
}
::-webkit-scrollbar-thumb:active {
background: lighten($ui-base-color, 4%);
}
::-webkit-scrollbar-track {
border: 0px none $base-border-color;
border-radius: 0;
background: rgba($base-overlay-background, 0.1);
}
::-webkit-scrollbar-track:hover {
background: $ui-base-color;
}
::-webkit-scrollbar-track:active {
background: $ui-base-color;
}
::-webkit-scrollbar-corner {
background: transparent;
}

View File

@ -101,9 +101,7 @@ class LinkDetailsExtractor
end end
def json def json
@json ||= root_array(Oj.load(@data)) @json ||= root_array(Oj.load(@data)).compact.find { |obj| SUPPORTED_TYPES.include?(obj['@type']) } || {}
.map { |node| JSON::LD::API.compact(node, 'https://schema.org') }
.find { |node| SUPPORTED_TYPES.include?(node['type']) } || {}
end end
end end

View File

@ -276,6 +276,9 @@ class MediaAttachment < ApplicationRecord
before_create :set_unknown_type before_create :set_unknown_type
before_create :set_processing before_create :set_processing
before_destroy :prepare_cache_bust!, prepend: true
after_destroy :bust_cache!
after_commit :enqueue_processing, on: :create after_commit :enqueue_processing, on: :create
after_commit :reset_parent_cache, on: :update after_commit :reset_parent_cache, on: :update
@ -410,4 +413,29 @@ class MediaAttachment < ApplicationRecord
def reset_parent_cache def reset_parent_cache
Rails.cache.delete("v3:statuses/#{status_id}") if status_id.present? Rails.cache.delete("v3:statuses/#{status_id}") if status_id.present?
end end
# Record the cache keys to burst before the file get actually deleted
def prepare_cache_bust!
return unless Rails.configuration.x.cache_buster_enabled
@paths_to_cache_bust = MediaAttachment.attachment_definitions.keys.flat_map do |attachment_name|
attachment = public_send(attachment_name)
styles = DEFAULT_STYLES | attachment.styles.keys
styles.map { |style| attachment.path(style) }
end
rescue => e
# We really don't want any error here preventing media deletion
Rails.logger.warn "Error #{e.class} busting cache: #{e.message}"
end
# Once Paperclip has deleted the files, we can't recover the cache keys,
# so use the previously-saved ones
def bust_cache!
return unless Rails.configuration.x.cache_buster_enabled
CacheBusterWorker.push_bulk(@paths_to_cache_bust) { |path| [path] }
rescue => e
# We really don't want any error here preventing media deletion
Rails.logger.warn "Error #{e.class} busting cache: #{e.message}"
end
end end

View File

@ -6,15 +6,23 @@
# #
# id :bigint(8) not null, primary key # id :bigint(8) not null, primary key
# account_id :bigint(8) not null # account_id :bigint(8) not null
# filter_not_following :boolean default(FALSE), not null
# filter_not_followers :boolean default(FALSE), not null
# filter_new_accounts :boolean default(FALSE), not null
# filter_private_mentions :boolean default(TRUE), not null
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# for_not_following :integer default("accept"), not null
# for_not_followers :integer default("accept"), not null
# for_new_accounts :integer default("accept"), not null
# for_private_mentions :integer default("filter"), not null
# for_limited_accounts :integer default("filter"), not null
# #
class NotificationPolicy < ApplicationRecord class NotificationPolicy < ApplicationRecord
self.ignored_columns += %w(
filter_not_following
filter_not_followers
filter_new_accounts
filter_private_mentions
)
belongs_to :account belongs_to :account
has_many :notification_requests, primary_key: :account_id, foreign_key: :account_id, dependent: nil, inverse_of: false has_many :notification_requests, primary_key: :account_id, foreign_key: :account_id, dependent: nil, inverse_of: false
@ -23,11 +31,34 @@ class NotificationPolicy < ApplicationRecord
MAX_MEANINGFUL_COUNT = 100 MAX_MEANINGFUL_COUNT = 100
enum :for_not_following, { accept: 0, filter: 1, drop: 2 }, suffix: :not_following
enum :for_not_followers, { accept: 0, filter: 1, drop: 2 }, suffix: :not_followers
enum :for_new_accounts, { accept: 0, filter: 1, drop: 2 }, suffix: :new_accounts
enum :for_private_mentions, { accept: 0, filter: 1, drop: 2 }, suffix: :private_mentions
enum :for_limited_accounts, { accept: 0, filter: 1, drop: 2 }, suffix: :limited_accounts
def summarize! def summarize!
@pending_requests_count = pending_notification_requests.first @pending_requests_count = pending_notification_requests.first
@pending_notifications_count = pending_notification_requests.last @pending_notifications_count = pending_notification_requests.last
end end
# Compat helpers with V1
def filter_not_following=(value)
self.for_not_following = value ? :filter : :accept
end
def filter_not_followers=(value)
self.for_not_followers = value ? :filter : :accept
end
def filter_new_accounts=(value)
self.for_new_accounts = value ? :filter : :accept
end
def filter_private_mentions=(value)
self.for_private_mentions = value ? :filter : :accept
end
private private
def pending_notification_requests def pending_notification_requests

View File

@ -3,10 +3,11 @@
class REST::NotificationPolicySerializer < ActiveModel::Serializer class REST::NotificationPolicySerializer < ActiveModel::Serializer
# Please update `app/javascript/mastodon/api_types/notification_policies.ts` when making changes to the attributes # Please update `app/javascript/mastodon/api_types/notification_policies.ts` when making changes to the attributes
attributes :filter_not_following, attributes :for_not_following,
:filter_not_followers, :for_not_followers,
:filter_new_accounts, :for_new_accounts,
:filter_private_mentions, :for_private_mentions,
:for_limited_accounts,
:summary :summary
def summary def summary

View File

@ -0,0 +1,32 @@
# frozen_string_literal: true
class REST::V1::NotificationPolicySerializer < ActiveModel::Serializer
attributes :filter_not_following,
:filter_not_followers,
:filter_new_accounts,
:filter_private_mentions,
:summary
def summary
{
pending_requests_count: object.pending_requests_count.to_i,
pending_notifications_count: object.pending_notifications_count.to_i,
}
end
def filter_not_following
!object.accept_not_following?
end
def filter_not_followers
!object.accept_not_followers?
end
def filter_new_accounts
!object.accept_new_accounts?
end
def filter_private_mentions
!object.accept_private_mentions?
end
end

View File

@ -16,59 +16,7 @@ class NotifyService < BaseService
severed_relationships severed_relationships
).freeze ).freeze
class DismissCondition class BaseCondition
def initialize(notification)
@recipient = notification.account
@sender = notification.from_account
@notification = notification
end
def dismiss?
blocked = @recipient.unavailable?
blocked ||= from_self? && %i(poll severed_relationships moderation_warning).exclude?(@notification.type)
return blocked if message? && from_staff?
blocked ||= domain_blocking?
blocked ||= @recipient.blocking?(@sender)
blocked ||= @recipient.muting_notifications?(@sender)
blocked ||= conversation_muted?
blocked ||= blocked_mention? if message?
blocked
end
private
def blocked_mention?
FeedManager.instance.filter?(:mentions, @notification.target_status, @recipient)
end
def message?
@notification.type == :mention
end
def from_staff?
@sender.local? && @sender.user.present? && @sender.user_role&.overrides?(@recipient.user_role) && @sender.user_role&.highlighted? && @sender.user_role&.can?(*UserRole::Flags::CATEGORIES[:moderation])
end
def from_self?
@recipient.id == @sender.id
end
def domain_blocking?
@recipient.domain_blocking?(@sender.domain) && !following_sender?
end
def conversation_muted?
@notification.target_status && @recipient.muting_conversation?(@notification.target_status.conversation)
end
def following_sender?
@recipient.following?(@sender)
end
end
class FilterCondition
NEW_ACCOUNT_THRESHOLD = 30.days.freeze NEW_ACCOUNT_THRESHOLD = 30.days.freeze
NEW_FOLLOWER_THRESHOLD = 3.days.freeze NEW_FOLLOWER_THRESHOLD = 3.days.freeze
@ -82,39 +30,16 @@ class NotifyService < BaseService
).freeze ).freeze
def initialize(notification) def initialize(notification)
@notification = notification
@recipient = notification.account @recipient = notification.account
@sender = notification.from_account @sender = notification.from_account
@notification = notification
@policy = NotificationPolicy.find_or_initialize_by(account: @recipient) @policy = NotificationPolicy.find_or_initialize_by(account: @recipient)
end end
def filter?
return false unless Notification::PROPERTIES[@notification.type][:filterable]
return false if override_for_sender?
from_limited? ||
filtered_by_not_following_policy? ||
filtered_by_not_followers_policy? ||
filtered_by_new_accounts_policy? ||
filtered_by_private_mentions_policy?
end
private private
def filtered_by_not_following_policy? def filterable_type?
@policy.filter_not_following? && not_following? Notification::PROPERTIES[@notification.type][:filterable]
end
def filtered_by_not_followers_policy?
@policy.filter_not_followers? && not_follower?
end
def filtered_by_new_accounts_policy?
@policy.filter_new_accounts? && new_account?
end
def filtered_by_private_mentions_policy?
@policy.filter_private_mentions? && not_following? && private_mention_not_in_response?
end end
def not_following? def not_following?
@ -174,6 +99,112 @@ class NotifyService < BaseService
end end
end end
class DropCondition < BaseCondition
def drop?
blocked = @recipient.unavailable?
blocked ||= from_self? && %i(poll severed_relationships moderation_warning).exclude?(@notification.type)
return blocked if message? && from_staff?
blocked ||= domain_blocking?
blocked ||= @recipient.blocking?(@sender)
blocked ||= @recipient.muting_notifications?(@sender)
blocked ||= conversation_muted?
blocked ||= blocked_mention? if message?
return true if blocked
return false unless filterable_type?
return false if override_for_sender?
blocked_by_limited_accounts_policy? ||
blocked_by_not_following_policy? ||
blocked_by_not_followers_policy? ||
blocked_by_new_accounts_policy? ||
blocked_by_private_mentions_policy?
end
private
def blocked_mention?
FeedManager.instance.filter?(:mentions, @notification.target_status, @recipient)
end
def message?
@notification.type == :mention
end
def from_staff?
@sender.local? && @sender.user.present? && @sender.user_role&.overrides?(@recipient.user_role) && @sender.user_role&.highlighted? && @sender.user_role&.can?(*UserRole::Flags::CATEGORIES[:moderation])
end
def from_self?
@recipient.id == @sender.id
end
def domain_blocking?
@recipient.domain_blocking?(@sender.domain) && not_following?
end
def conversation_muted?
@notification.target_status && @recipient.muting_conversation?(@notification.target_status.conversation)
end
def blocked_by_not_following_policy?
@policy.drop_not_following? && not_following?
end
def blocked_by_not_followers_policy?
@policy.drop_not_followers? && not_follower?
end
def blocked_by_new_accounts_policy?
@policy.drop_new_accounts? && new_account? && not_following?
end
def blocked_by_private_mentions_policy?
@policy.drop_private_mentions? && not_following? && private_mention_not_in_response?
end
def blocked_by_limited_accounts_policy?
@policy.drop_limited_accounts? && @sender.silenced? && not_following?
end
end
class FilterCondition < BaseCondition
def filter?
return false unless filterable_type?
return false if override_for_sender?
filtered_by_limited_accounts_policy? ||
filtered_by_not_following_policy? ||
filtered_by_not_followers_policy? ||
filtered_by_new_accounts_policy? ||
filtered_by_private_mentions_policy?
end
private
def filtered_by_not_following_policy?
@policy.filter_not_following? && not_following?
end
def filtered_by_not_followers_policy?
@policy.filter_not_followers? && not_follower?
end
def filtered_by_new_accounts_policy?
@policy.filter_new_accounts? && new_account? && not_following?
end
def filtered_by_private_mentions_policy?
@policy.filter_private_mentions? && not_following? && private_mention_not_in_response?
end
def filtered_by_limited_accounts_policy?
@policy.filter_limited_accounts? && @sender.silenced? && not_following?
end
end
def call(recipient, type, activity) def call(recipient, type, activity)
return if recipient.user.nil? return if recipient.user.nil?
@ -182,7 +213,7 @@ class NotifyService < BaseService
@notification = Notification.new(account: @recipient, type: type, activity: @activity) @notification = Notification.new(account: @recipient, type: type, activity: @activity)
# For certain conditions we don't need to create a notification at all # For certain conditions we don't need to create a notification at all
return if dismiss? return if drop?
@notification.filtered = filter? @notification.filtered = filter?
@notification.group_key = notification_group_key @notification.group_key = notification_group_key
@ -222,8 +253,8 @@ class NotifyService < BaseService
"#{type_prefix}-#{hour_bucket}" "#{type_prefix}-#{hour_bucket}"
end end
def dismiss? def drop?
DismissCondition.new(@notification).dismiss? DropCondition.new(@notification).drop?
end end
def filter? def filter?

View File

@ -4,12 +4,12 @@
.batch-table__row__content.pending-account .batch-table__row__content.pending-account
.pending-account__header .pending-account__header
= link_to preview_card.title, url_for_preview_card(preview_card) = link_to preview_card.title, url_for_preview_card(preview_card), lang: preview_card.language
%br/ %br/
- if preview_card.provider_name.present? - if preview_card.provider_name.present?
= preview_card.provider_name %span{ lang: preview_card.language }= preview_card.provider_name
· ·
- if preview_card.language.present? - if preview_card.language.present?

View File

@ -39,22 +39,22 @@
.batch-table__toolbar__actions .batch-table__toolbar__actions
= f.button safe_join([material_symbol('check'), t('admin.trends.links.allow')]), = f.button safe_join([material_symbol('check'), t('admin.trends.links.allow')]),
class: 'table-action-link', class: 'table-action-link',
data: { confirm: t('admin.reports.are_you_sure') }, data: { confirm: t('admin.trends.links.confirm_allow') },
name: :approve, name: :approve,
type: :submit type: :submit
= f.button safe_join([material_symbol('check'), t('admin.trends.links.allow_provider')]), = f.button safe_join([material_symbol('check'), t('admin.trends.links.allow_provider')]),
class: 'table-action-link', class: 'table-action-link',
data: { confirm: t('admin.reports.are_you_sure') }, data: { confirm: t('admin.trends.links.confirm_allow_provider') },
name: :approve_providers, name: :approve_providers,
type: :submit type: :submit
= f.button safe_join([material_symbol('close'), t('admin.trends.links.disallow')]), = f.button safe_join([material_symbol('close'), t('admin.trends.links.disallow')]),
class: 'table-action-link', class: 'table-action-link',
data: { confirm: t('admin.reports.are_you_sure') }, data: { confirm: t('admin.trends.links.confirm_disallow') },
name: :reject, name: :reject,
type: :submit type: :submit
= f.button safe_join([material_symbol('close'), t('admin.trends.links.disallow_provider')]), = f.button safe_join([material_symbol('close'), t('admin.trends.links.disallow_provider')]),
class: 'table-action-link', class: 'table-action-link',
data: { confirm: t('admin.reports.are_you_sure') }, data: { confirm: t('admin.trends.links.confirm_disallow_provider') },
name: :reject_providers, name: :reject_providers,
type: :submit type: :submit
.batch-table__body .batch-table__body

View File

@ -35,22 +35,22 @@
.batch-table__toolbar__actions .batch-table__toolbar__actions
= f.button safe_join([material_symbol('check'), t('admin.trends.statuses.allow')]), = f.button safe_join([material_symbol('check'), t('admin.trends.statuses.allow')]),
class: 'table-action-link', class: 'table-action-link',
data: { confirm: t('admin.reports.are_you_sure') }, data: { confirm: t('admin.trends.statuses.confirm_allow') },
name: :approve, name: :approve,
type: :submit type: :submit
= f.button safe_join([material_symbol('check'), t('admin.trends.statuses.allow_account')]), = f.button safe_join([material_symbol('check'), t('admin.trends.statuses.allow_account')]),
class: 'table-action-link', class: 'table-action-link',
data: { confirm: t('admin.reports.are_you_sure') }, data: { confirm: t('admin.trends.statuses.confirm_allow_account') },
name: :approve_accounts, name: :approve_accounts,
type: :submit type: :submit
= f.button safe_join([material_symbol('close'), t('admin.trends.statuses.disallow')]), = f.button safe_join([material_symbol('close'), t('admin.trends.statuses.disallow')]),
class: 'table-action-link', class: 'table-action-link',
data: { confirm: t('admin.reports.are_you_sure') }, data: { confirm: t('admin.trends.statuses.confirm_disallow') },
name: :reject, name: :reject,
type: :submit type: :submit
= f.button safe_join([material_symbol('close'), t('admin.trends.statuses.disallow_account')]), = f.button safe_join([material_symbol('close'), t('admin.trends.statuses.disallow_account')]),
class: 'table-action-link', class: 'table-action-link',
data: { confirm: t('admin.reports.are_you_sure') }, data: { confirm: t('admin.trends.statuses.confirm_disallow_account') },
name: :reject_accounts, name: :reject_accounts,
type: :submit type: :submit
.batch-table__body .batch-table__body

View File

@ -27,12 +27,12 @@
.batch-table__toolbar__actions .batch-table__toolbar__actions
= f.button safe_join([material_symbol('check'), t('admin.trends.allow')]), = f.button safe_join([material_symbol('check'), t('admin.trends.allow')]),
class: 'table-action-link', class: 'table-action-link',
data: { confirm: t('admin.reports.are_you_sure') }, data: { confirm: t('admin.trends.confirm_allow') },
name: :approve, name: :approve,
type: :submit type: :submit
= f.button safe_join([material_symbol('close'), t('admin.trends.disallow')]), = f.button safe_join([material_symbol('close'), t('admin.trends.disallow')]),
class: 'table-action-link', class: 'table-action-link',
data: { confirm: t('admin.reports.are_you_sure') }, data: { confirm: t('admin.trends.confirm_disallow') },
name: :reject, name: :reject,
type: :submit type: :submit

View File

@ -142,7 +142,7 @@ class Rack::Attack
end end
throttle('throttle_password_change/account', limit: 10, period: 10.minutes) do |req| throttle('throttle_password_change/account', limit: 10, period: 10.minutes) do |req|
req.warden_user_id if req.put? || (req.patch? && req.path_matches?('/auth')) req.warden_user_id if (req.put? || req.patch?) && (req.path_matches?('/auth') || req.path_matches?('/auth/password'))
end end
self.throttled_responder = lambda do |request| self.throttled_responder = lambda do |request|

View File

@ -907,10 +907,16 @@ en:
trends: trends:
allow: Allow allow: Allow
approved: Approved approved: Approved
confirm_allow: Are you sure you want to allow selected tags?
confirm_disallow: Are you sure you want to disallow selected tags?
disallow: Disallow disallow: Disallow
links: links:
allow: Allow link allow: Allow link
allow_provider: Allow publisher allow_provider: Allow publisher
confirm_allow: Are you sure you want to allow selected links?
confirm_allow_provider: Are you sure you want to allow selected providers?
confirm_disallow: Are you sure you want to disallow selected links?
confirm_disallow_provider: Are you sure you want to disallow selected providers?
description_html: These are links that are currently being shared a lot by accounts that your server sees posts from. It can help your users find out what's going on in the world. No links are displayed publicly until you approve the publisher. You can also allow or reject individual links. description_html: These are links that are currently being shared a lot by accounts that your server sees posts from. It can help your users find out what's going on in the world. No links are displayed publicly until you approve the publisher. You can also allow or reject individual links.
disallow: Disallow link disallow: Disallow link
disallow_provider: Disallow publisher disallow_provider: Disallow publisher
@ -934,6 +940,10 @@ en:
statuses: statuses:
allow: Allow post allow: Allow post
allow_account: Allow author allow_account: Allow author
confirm_allow: Are you sure you want to allow selected statuses?
confirm_allow_account: Are you sure you want to allow selected accounts?
confirm_disallow: Are you sure you want to disallow selected statuses?
confirm_disallow_account: Are you sure you want to disallow selected accounts?
description_html: These are posts that your server knows about that are currently being shared and favorited a lot at the moment. It can help your new and returning users to find more people to follow. No posts are displayed publicly until you approve the author, and the author allows their account to be suggested to others. You can also allow or reject individual posts. description_html: These are posts that your server knows about that are currently being shared and favorited a lot at the moment. It can help your new and returning users to find more people to follow. No posts are displayed publicly until you approve the author, and the author allows their account to be suggested to others. You can also allow or reject individual posts.
disallow: Disallow post disallow: Disallow post
disallow_account: Disallow author disallow_account: Disallow author

View File

@ -338,6 +338,10 @@ namespace :api, format: false do
namespace :admin do namespace :admin do
resources :accounts, only: [:index] resources :accounts, only: [:index]
end end
namespace :notifications do
resource :policy, only: [:show, :update]
end
end end
namespace :v2_alpha do namespace :v2_alpha do

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
class AddNewNotificationPolicies < ActiveRecord::Migration[7.1]
def change
add_column :notification_policies, :for_not_following, :integer, default: 0, null: false
add_column :notification_policies, :for_not_followers, :integer, default: 0, null: false
add_column :notification_policies, :for_new_accounts, :integer, default: 0, null: false
add_column :notification_policies, :for_private_mentions, :integer, default: 1, null: false
add_column :notification_policies, :for_limited_accounts, :integer, default: 1, null: false
end
end

View File

@ -0,0 +1,26 @@
# frozen_string_literal: true
class MigrateNotificationsPolicyV2 < ActiveRecord::Migration[7.1]
disable_ddl_transaction!
# Dummy classes, to make migration possible across version changes
class NotificationPolicy < ApplicationRecord; end
def up
NotificationPolicy.in_batches.update_all(<<~SQL.squish)
for_not_following = CASE filter_not_following WHEN true THEN 1 ELSE 0 END,
for_not_followers = CASE filter_not_following WHEN true THEN 1 ELSE 0 END,
for_new_accounts = CASE filter_new_accounts WHEN true THEN 1 ELSE 0 END,
for_private_mentions = CASE filter_private_mentions WHEN true THEN 1 ELSE 0 END
SQL
end
def down
NotificationPolicy.in_batches.update_all(<<~SQL.squish)
filter_not_following = CASE for_not_following WHEN 0 THEN false ELSE true END,
filter_not_following = CASE for_not_followers WHEN 0 THEN false ELSE true END,
filter_new_accounts = CASE for_new_accounts WHEN 0 THEN false ELSE true END,
filter_private_mentions = CASE for_private_mentions WHEN 0 THEN false ELSE true END
SQL
end
end

View File

@ -0,0 +1,26 @@
# frozen_string_literal: true
class PostDeploymentMigrateNotificationsPolicyV2 < ActiveRecord::Migration[7.1]
disable_ddl_transaction!
# Dummy classes, to make migration possible across version changes
class NotificationPolicy < ApplicationRecord; end
def up
NotificationPolicy.in_batches.update_all(<<~SQL.squish)
for_not_following = CASE filter_not_following WHEN true THEN 1 ELSE 0 END,
for_not_followers = CASE filter_not_following WHEN true THEN 1 ELSE 0 END,
for_new_accounts = CASE filter_new_accounts WHEN true THEN 1 ELSE 0 END,
for_private_mentions = CASE filter_private_mentions WHEN true THEN 1 ELSE 0 END
SQL
end
def down
NotificationPolicy.in_batches.update_all(<<~SQL.squish)
filter_not_following = CASE for_not_following WHEN 0 THEN false ELSE true END,
filter_not_following = CASE for_not_followers WHEN 0 THEN false ELSE true END,
filter_new_accounts = CASE for_new_accounts WHEN 0 THEN false ELSE true END,
filter_private_mentions = CASE for_private_mentions WHEN 0 THEN false ELSE true END
SQL
end
end

View File

@ -0,0 +1,12 @@
# frozen_string_literal: true
class DropOldPoliciesFromNotificationsPolicy < ActiveRecord::Migration[7.1]
def change
safety_assured do
remove_column :notification_policies, :filter_not_following, :boolean, default: false, null: false
remove_column :notification_policies, :filter_not_followers, :boolean, default: false, null: false
remove_column :notification_policies, :filter_new_accounts, :boolean, default: false, null: false
remove_column :notification_policies, :filter_private_mentions, :boolean, default: true, null: false
end
end
end

View File

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2024_07_24_181224) do ActiveRecord::Schema[7.1].define(version: 2024_08_08_125420) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@ -692,12 +692,13 @@ ActiveRecord::Schema[7.1].define(version: 2024_07_24_181224) do
create_table "notification_policies", force: :cascade do |t| create_table "notification_policies", force: :cascade do |t|
t.bigint "account_id", null: false t.bigint "account_id", null: false
t.boolean "filter_not_following", default: false, null: false
t.boolean "filter_not_followers", default: false, null: false
t.boolean "filter_new_accounts", default: false, null: false
t.boolean "filter_private_mentions", default: true, null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.integer "for_not_following", default: 0, null: false
t.integer "for_not_followers", default: 0, null: false
t.integer "for_new_accounts", default: 0, null: false
t.integer "for_private_mentions", default: 1, null: false
t.integer "for_limited_accounts", default: 1, null: false
t.index ["account_id"], name: "index_notification_policies_on_account_id", unique: true t.index ["account_id"], name: "index_notification_policies_on_account_id", unique: true
end end

View File

@ -107,8 +107,8 @@ namespace :tests do
end end
policy = NotificationPolicy.find_by(account: User.find(1).account) policy = NotificationPolicy.find_by(account: User.find(1).account)
unless policy.filter_private_mentions == false && policy.filter_not_following == true unless policy.for_private_mentions == 'accept' && policy.for_not_following == 'filter'
puts 'Notification policy not migrated as expected' puts "Notification policy not migrated as expected: #{policy.for_private_mentions.inspect}, #{policy.for_not_following.inspect}"
exit(1) exit(1)
end end

View File

@ -180,9 +180,9 @@
"eslint-import-resolver-typescript": "^3.5.5", "eslint-import-resolver-typescript": "^3.5.5",
"eslint-plugin-formatjs": "^4.10.1", "eslint-plugin-formatjs": "^4.10.1",
"eslint-plugin-import": "~2.29.0", "eslint-plugin-import": "~2.29.0",
"eslint-plugin-jsdoc": "^48.0.0", "eslint-plugin-jsdoc": "^50.0.0",
"eslint-plugin-jsx-a11y": "~6.9.0", "eslint-plugin-jsx-a11y": "~6.9.0",
"eslint-plugin-promise": "~6.6.0", "eslint-plugin-promise": "~7.1.0",
"eslint-plugin-react": "^7.33.2", "eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"husky": "^9.0.11", "husky": "^9.0.11",
@ -192,6 +192,7 @@
"prettier": "^3.3.3", "prettier": "^3.3.3",
"react-test-renderer": "^18.2.0", "react-test-renderer": "^18.2.0",
"stylelint": "^16.0.2", "stylelint": "^16.0.2",
"stylelint-config-prettier-scss": "^1.0.0",
"stylelint-config-standard-scss": "^13.0.0", "stylelint-config-standard-scss": "^13.0.0",
"typescript": "^5.0.4", "typescript": "^5.0.4",
"webpack-dev-server": "^3.11.3" "webpack-dev-server": "^3.11.3"

View File

@ -79,16 +79,6 @@ RSpec.describe LinkDetailsExtractor do
}, },
}.to_json }.to_json
end end
let(:html) { <<~HTML }
<!doctype html>
<html>
<body>
<script type="application/ld+json">
#{ld_json}
</script>
</body>
</html>
HTML
shared_examples 'structured data' do shared_examples 'structured data' do
it 'extracts the expected values from structured data' do it 'extracts the expected values from structured data' do
@ -234,27 +224,21 @@ RSpec.describe LinkDetailsExtractor do
}, },
}.to_json }.to_json
end end
let(:html) { <<~HTML }
<!doctype html>
<html>
<body>
<script type="application/ld+json">
#{ld_json}
</script>
</body>
</html>
HTML
it 'joins author names' do it 'joins author names' do
expect(subject.author_name).to eq 'Author 1, Author 2' expect(subject.author_name).to eq 'Author 1, Author 2'
end end
end end
context 'with named graph' do
let(:ld_json) do
{
'@context' => 'https://schema.org',
'@graph' => [
'@type' => 'NewsArticle',
'headline' => "What's in a name",
],
}.to_json
end
it 'descends into @graph node' do
expect(subject.title).to eq "What's in a name"
end
end
end end
context 'when Open Graph protocol data is present' do context 'when Open Graph protocol data is present' do

View File

@ -292,6 +292,25 @@ RSpec.describe MediaAttachment, :attachment_processing do
end end
end end
describe 'cache deletion hooks' do
let(:media) { Fabricate(:media_attachment) }
before do
allow(Rails.configuration.x).to receive(:cache_buster_enabled).and_return(true)
end
it 'queues CacheBusterWorker jobs' do
original_path = media.file.path(:original)
small_path = media.file.path(:small)
thumbnail_path = media.thumbnail.path(:original)
expect { media.destroy }
.to enqueue_sidekiq_job(CacheBusterWorker).with(original_path)
.and enqueue_sidekiq_job(CacheBusterWorker).with(small_path)
.and enqueue_sidekiq_job(CacheBusterWorker).with(thumbnail_path)
end
end
private private
def media_metadata def media_metadata

View File

@ -51,7 +51,7 @@ RSpec.describe 'Policies' do
it 'changes notification policy and returns an updated json object', :aggregate_failures do it 'changes notification policy and returns an updated json object', :aggregate_failures do
expect { subject } expect { subject }
.to change { NotificationPolicy.find_or_initialize_by(account: user.account).filter_not_following }.from(false).to(true) .to change { NotificationPolicy.find_or_initialize_by(account: user.account).for_not_following.to_sym }.from(:accept).to(:filter)
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
expect(body_as_json).to include( expect(body_as_json).to include(

View File

@ -0,0 +1,72 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Policies' do
let(:user) { Fabricate(:user, account_attributes: { username: 'alice' }) }
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
let(:scopes) { 'read:notifications write:notifications' }
let(:headers) { { 'Authorization' => "Bearer #{token.token}" } }
describe 'GET /api/v2/notifications/policy', :inline_jobs do
subject do
get '/api/v2/notifications/policy', headers: headers, params: params
end
let(:params) { {} }
before do
Fabricate(:notification_request, account: user.account)
end
it_behaves_like 'forbidden for wrong scope', 'write write:notifications'
context 'with no options' do
it 'returns json with expected attributes', :aggregate_failures do
subject
expect(response).to have_http_status(200)
expect(body_as_json).to include(
for_not_following: 'accept',
for_not_followers: 'accept',
for_new_accounts: 'accept',
for_private_mentions: 'filter',
for_limited_accounts: 'filter',
summary: a_hash_including(
pending_requests_count: 1,
pending_notifications_count: 0
)
)
end
end
end
describe 'PUT /api/v2/notifications/policy' do
subject do
put '/api/v2/notifications/policy', headers: headers, params: params
end
let(:params) { { for_not_following: 'filter', for_limited_accounts: 'drop' } }
it_behaves_like 'forbidden for wrong scope', 'read read:notifications'
it 'changes notification policy and returns an updated json object', :aggregate_failures do
expect { subject }
.to change { NotificationPolicy.find_or_initialize_by(account: user.account).for_not_following.to_sym }.from(:accept).to(:filter)
.and change { NotificationPolicy.find_or_initialize_by(account: user.account).for_limited_accounts.to_sym }.from(:filter).to(:drop)
expect(response).to have_http_status(200)
expect(body_as_json).to include(
for_not_following: 'filter',
for_not_followers: 'accept',
for_new_accounts: 'accept',
for_private_mentions: 'filter',
for_limited_accounts: 'drop',
summary: a_hash_including(
pending_requests_count: 0,
pending_notifications_count: 0
)
)
end
end
end

View File

@ -196,20 +196,58 @@ RSpec.describe NotifyService do
end end
end end
describe NotifyService::DismissCondition do describe NotifyService::DropCondition do
subject { described_class.new(notification) } subject { described_class.new(notification) }
let(:activity) { Fabricate(:mention, status: Fabricate(:status)) } let(:activity) { Fabricate(:mention, status: Fabricate(:status)) }
let(:notification) { Fabricate(:notification, type: :mention, activity: activity, from_account: activity.status.account, account: activity.account) } let(:notification) { Fabricate(:notification, type: :mention, activity: activity, from_account: activity.status.account, account: activity.account) }
describe '#dismiss?' do describe '#drop' do
context 'when sender is silenced' do context 'when sender is silenced and recipient has a default policy' do
before do before do
notification.from_account.silence! notification.from_account.silence!
end end
it 'returns false' do it 'returns false' do
expect(subject.dismiss?).to be false expect(subject.drop?).to be false
end
end
context 'when sender is silenced and recipient has a policy to ignore silenced accounts' do
before do
notification.from_account.silence!
notification.account.create_notification_policy!(for_limited_accounts: :drop)
end
it 'returns true' do
expect(subject.drop?).to be true
end
end
context 'when sender is new and recipient has a default policy' do
it 'returns false' do
expect(subject.drop?).to be false
end
end
context 'when sender is new and recipient has a policy to ignore silenced accounts' do
before do
notification.account.create_notification_policy!(for_new_accounts: :drop)
end
it 'returns true' do
expect(subject.drop?).to be true
end
end
context 'when sender is new and followed and recipient has a policy to ignore silenced accounts' do
before do
notification.account.create_notification_policy!(for_new_accounts: :drop)
notification.account.follow!(notification.from_account)
end
it 'returns false' do
expect(subject.drop?).to be false
end end
end end
@ -219,7 +257,7 @@ RSpec.describe NotifyService do
end end
it 'returns true' do it 'returns true' do
expect(subject.dismiss?).to be true expect(subject.drop?).to be true
end end
end end
end end
@ -250,6 +288,16 @@ RSpec.describe NotifyService do
expect(subject.filter?).to be false expect(subject.filter?).to be false
end end
end end
context 'when recipient is allowing limited accounts' do
before do
notification.account.create_notification_policy!(for_limited_accounts: :accept)
end
it 'returns false' do
expect(subject.filter?).to be false
end
end
end end
context 'when recipient is filtering not-followed senders' do context 'when recipient is filtering not-followed senders' do

View File

@ -1,5 +1,5 @@
module.exports = { module.exports = {
extends: ['stylelint-config-standard-scss'], extends: ['stylelint-config-standard-scss', 'stylelint-config-prettier-scss'],
ignoreFiles: [ ignoreFiles: [
'app/javascript/styles/mastodon/reset.scss', 'app/javascript/styles/mastodon/reset.scss',
'app/javascript/flavours/glitch/styles/reset.scss', 'app/javascript/flavours/glitch/styles/reset.scss',

View File

@ -2886,9 +2886,9 @@ __metadata:
eslint-import-resolver-typescript: "npm:^3.5.5" eslint-import-resolver-typescript: "npm:^3.5.5"
eslint-plugin-formatjs: "npm:^4.10.1" eslint-plugin-formatjs: "npm:^4.10.1"
eslint-plugin-import: "npm:~2.29.0" eslint-plugin-import: "npm:~2.29.0"
eslint-plugin-jsdoc: "npm:^48.0.0" eslint-plugin-jsdoc: "npm:^50.0.0"
eslint-plugin-jsx-a11y: "npm:~6.9.0" eslint-plugin-jsx-a11y: "npm:~6.9.0"
eslint-plugin-promise: "npm:~6.6.0" eslint-plugin-promise: "npm:~7.1.0"
eslint-plugin-react: "npm:^7.33.2" eslint-plugin-react: "npm:^7.33.2"
eslint-plugin-react-hooks: "npm:^4.6.0" eslint-plugin-react-hooks: "npm:^4.6.0"
exif-js: "npm:^2.3.0" exif-js: "npm:^2.3.0"
@ -2948,6 +2948,7 @@ __metadata:
stacktrace-js: "npm:^2.0.2" stacktrace-js: "npm:^2.0.2"
stringz: "npm:^2.1.0" stringz: "npm:^2.1.0"
stylelint: "npm:^16.0.2" stylelint: "npm:^16.0.2"
stylelint-config-prettier-scss: "npm:^1.0.0"
stylelint-config-standard-scss: "npm:^13.0.0" stylelint-config-standard-scss: "npm:^13.0.0"
substring-trie: "npm:^1.0.2" substring-trie: "npm:^1.0.2"
terser-webpack-plugin: "npm:^4.2.3" terser-webpack-plugin: "npm:^4.2.3"
@ -4620,12 +4621,12 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"acorn@npm:^8.0.4, acorn@npm:^8.1.0, acorn@npm:^8.8.1, acorn@npm:^8.8.2, acorn@npm:^8.9.0": "acorn@npm:^8.0.4, acorn@npm:^8.1.0, acorn@npm:^8.12.0, acorn@npm:^8.8.1, acorn@npm:^8.8.2, acorn@npm:^8.9.0":
version: 8.11.2 version: 8.12.1
resolution: "acorn@npm:8.11.2" resolution: "acorn@npm:8.12.1"
bin: bin:
acorn: bin/acorn acorn: bin/acorn
checksum: 10c0/a3ed76c761b75ec54b1ec3068fb7f113a182e95aea7f322f65098c2958d232e3d211cb6dac35ff9c647024b63714bc528a26d54a925d1fef2c25585b4c8e4017 checksum: 10c0/51fb26cd678f914e13287e886da2d7021f8c2bc0ccc95e03d3e0447ee278dd3b40b9c57dc222acd5881adcf26f3edc40901a4953403232129e3876793cd17386
languageName: node languageName: node
linkType: hard linkType: hard
@ -7977,15 +7978,16 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"eslint-plugin-jsdoc@npm:^48.0.0": "eslint-plugin-jsdoc@npm:^50.0.0":
version: 48.8.3 version: 50.0.0
resolution: "eslint-plugin-jsdoc@npm:48.8.3" resolution: "eslint-plugin-jsdoc@npm:50.0.0"
dependencies: dependencies:
"@es-joy/jsdoccomment": "npm:~0.46.0" "@es-joy/jsdoccomment": "npm:~0.46.0"
are-docs-informative: "npm:^0.0.2" are-docs-informative: "npm:^0.0.2"
comment-parser: "npm:1.4.1" comment-parser: "npm:1.4.1"
debug: "npm:^4.3.5" debug: "npm:^4.3.5"
escape-string-regexp: "npm:^4.0.0" escape-string-regexp: "npm:^4.0.0"
espree: "npm:^10.1.0"
esquery: "npm:^1.6.0" esquery: "npm:^1.6.0"
parse-imports: "npm:^2.1.1" parse-imports: "npm:^2.1.1"
semver: "npm:^7.6.3" semver: "npm:^7.6.3"
@ -7993,7 +7995,7 @@ __metadata:
synckit: "npm:^0.9.1" synckit: "npm:^0.9.1"
peerDependencies: peerDependencies:
eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 eslint: ^7.0.0 || ^8.0.0 || ^9.0.0
checksum: 10c0/78d893614b188617de5a03d8163406455e3b739fd7b86192eb05a29cf8e7f06909a6f6a1b9dc2acd31e5ae2bccd94600eaea247d277f58c3c946c0fdb36a57f7 checksum: 10c0/1d476eabdf604f4a07ef9a22fb7b13ba898d0aed81b2c428d4b6aea766b908ebdc7e6e82a16bac3f83e1013c6edba6d9a15a4015cab9a94c584ebccbd7255b70
languageName: node languageName: node
linkType: hard linkType: hard
@ -8023,12 +8025,12 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"eslint-plugin-promise@npm:~6.6.0": "eslint-plugin-promise@npm:~7.1.0":
version: 6.6.0 version: 7.1.0
resolution: "eslint-plugin-promise@npm:6.6.0" resolution: "eslint-plugin-promise@npm:7.1.0"
peerDependencies: peerDependencies:
eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 eslint: ^7.0.0 || ^8.0.0 || ^9.0.0
checksum: 10c0/93a667dbc9ff15c4d586b0d40a31c7828314cbbb31b2b9a75802aa4ef536e9457bb3e1a89b384b07aa336dd61b315ae8b0aadc0870210378023dd018819b59b3 checksum: 10c0/bbc3406139715dfa5f48d04f6d5b5e82f68929d954b0fa3821eb8cd6dc381b210512cedd2d874e5de5381005d316566f4ae046a4750ce3f5f5cbf28a14cc0ab2
languageName: node languageName: node
linkType: hard linkType: hard
@ -8096,6 +8098,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"eslint-visitor-keys@npm:^4.0.0":
version: 4.0.0
resolution: "eslint-visitor-keys@npm:4.0.0"
checksum: 10c0/76619f42cf162705a1515a6868e6fc7567e185c7063a05621a8ac4c3b850d022661262c21d9f1fc1d144ecf0d5d64d70a3f43c15c3fc969a61ace0fb25698cf5
languageName: node
linkType: hard
"eslint@npm:^8.41.0": "eslint@npm:^8.41.0":
version: 8.57.0 version: 8.57.0
resolution: "eslint@npm:8.57.0" resolution: "eslint@npm:8.57.0"
@ -8144,6 +8153,17 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"espree@npm:^10.1.0":
version: 10.1.0
resolution: "espree@npm:10.1.0"
dependencies:
acorn: "npm:^8.12.0"
acorn-jsx: "npm:^5.3.2"
eslint-visitor-keys: "npm:^4.0.0"
checksum: 10c0/52e6feaa77a31a6038f0c0e3fce93010a4625701925b0715cd54a2ae190b3275053a0717db698697b32653788ac04845e489d6773b508d6c2e8752f3c57470a0
languageName: node
linkType: hard
"espree@npm:^9.6.0, espree@npm:^9.6.1": "espree@npm:^9.6.0, espree@npm:^9.6.1":
version: 9.6.1 version: 9.6.1
resolution: "espree@npm:9.6.1" resolution: "espree@npm:9.6.1"
@ -16619,6 +16639,18 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"stylelint-config-prettier-scss@npm:^1.0.0":
version: 1.0.0
resolution: "stylelint-config-prettier-scss@npm:1.0.0"
peerDependencies:
stylelint: ">=15.0.0"
bin:
stylelint-config-prettier-scss: bin/check.js
stylelint-config-prettier-scss-check: bin/check.js
checksum: 10c0/4d5e1d1c200d4611b5b7bd2d2528cc9e301f26645802a2774aec192c4c2949cbf5a0147eba8b2e6e4ff14a071b03024f3034bb1b4fda37a8ed5a0081a9597d4d
languageName: node
linkType: hard
"stylelint-config-recommended-scss@npm:^14.0.0": "stylelint-config-recommended-scss@npm:^14.0.0":
version: 14.0.0 version: 14.0.0
resolution: "stylelint-config-recommended-scss@npm:14.0.0" resolution: "stylelint-config-recommended-scss@npm:14.0.0"