Merge pull request #2809 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes up to 28411acebb
pull/2810/head
commit
e6feabf6c5
|
@ -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
|
||||||
|
|
|
@ -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
|
|
@ -77,6 +77,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';
|
||||||
|
@ -584,6 +592,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 };
|
||||||
|
|
|
@ -2,8 +2,8 @@ import { apiRequestGet, apiRequestPut } from 'flavours/glitch/api';
|
||||||
import type { NotificationPolicyJSON } from 'flavours/glitch/api_types/notification_policies';
|
import type { NotificationPolicyJSON } from 'flavours/glitch/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);
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 'flavours/glitch/actions/blocks';
|
||||||
|
import { initMuteModal } from 'flavours/glitch/actions/mutes';
|
||||||
import { acceptNotificationRequest, dismissNotificationRequest } from 'flavours/glitch/actions/notifications';
|
import { acceptNotificationRequest, dismissNotificationRequest } from 'flavours/glitch/actions/notifications';
|
||||||
|
import { initReport } from 'flavours/glitch/actions/reports';
|
||||||
import { Avatar } from 'flavours/glitch/components/avatar';
|
import { Avatar } from 'flavours/glitch/components/avatar';
|
||||||
|
import { CheckBox } from 'flavours/glitch/components/check_box';
|
||||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
import { IconButton } from 'flavours/glitch/components/icon_button';
|
||||||
|
import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
|
||||||
import { makeGetAccount } from 'flavours/glitch/selectors';
|
import { makeGetAccount } from 'flavours/glitch/selectors';
|
||||||
import { toCappedNumber } from 'flavours/glitch/utils/numbers';
|
import { toCappedNumber } from 'flavours/glitch/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,
|
||||||
};
|
};
|
||||||
|
|
|
@ -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 'flavours/glitch/actions/modal';
|
||||||
import { updateNotificationsPolicy } from 'flavours/glitch/actions/notification_policies';
|
import { updateNotificationsPolicy } from 'flavours/glitch/actions/notification_policies';
|
||||||
|
import type { AppDispatch } from 'flavours/glitch/store';
|
||||||
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
|
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
|
||||||
|
|
||||||
import { CheckboxWithLabel } from './checkbox_with_label';
|
import { SelectWithLabel } from './select_with_label';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
const 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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -0,0 +1,153 @@
|
||||||
|
import type { PropsWithChildren } from 'react';
|
||||||
|
import { useCallback, useState, useRef } from 'react';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import type { Placement, State as PopperState } from '@popperjs/core';
|
||||||
|
import Overlay from 'react-overlays/Overlay';
|
||||||
|
|
||||||
|
import ArrowDropDownIcon from '@/material-icons/400-24px/arrow_drop_down.svg?react';
|
||||||
|
import type { SelectItem } from 'flavours/glitch/components/dropdown_selector';
|
||||||
|
import { DropdownSelector } from 'flavours/glitch/components/dropdown_selector';
|
||||||
|
import { Icon } from 'flavours/glitch/components/icon';
|
||||||
|
|
||||||
|
interface DropdownProps {
|
||||||
|
value: string;
|
||||||
|
options: SelectItem[];
|
||||||
|
disabled?: boolean;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placement?: Placement;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Dropdown: React.FC<DropdownProps> = ({
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
disabled,
|
||||||
|
onChange,
|
||||||
|
placement: initialPlacement = 'bottom-end',
|
||||||
|
}) => {
|
||||||
|
const activeElementRef = useRef<Element | null>(null);
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
const [isOpen, setOpen] = useState<boolean>(false);
|
||||||
|
const [placement, setPlacement] = useState<Placement>(initialPlacement);
|
||||||
|
|
||||||
|
const handleToggle = useCallback(() => {
|
||||||
|
if (
|
||||||
|
isOpen &&
|
||||||
|
activeElementRef.current &&
|
||||||
|
activeElementRef.current instanceof HTMLElement
|
||||||
|
) {
|
||||||
|
activeElementRef.current.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpen(!isOpen);
|
||||||
|
}, [isOpen, setOpen]);
|
||||||
|
|
||||||
|
const handleMouseDown = useCallback(() => {
|
||||||
|
if (!isOpen) activeElementRef.current = document.activeElement;
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
switch (e.key) {
|
||||||
|
case ' ':
|
||||||
|
case 'Enter':
|
||||||
|
if (!isOpen) activeElementRef.current = document.activeElement;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isOpen],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
if (
|
||||||
|
isOpen &&
|
||||||
|
activeElementRef.current &&
|
||||||
|
activeElementRef.current instanceof HTMLElement
|
||||||
|
)
|
||||||
|
activeElementRef.current.focus({ preventScroll: true });
|
||||||
|
setOpen(false);
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleOverlayEnter = useCallback(
|
||||||
|
(state: Partial<PopperState>) => {
|
||||||
|
if (state.placement) setPlacement(state.placement);
|
||||||
|
},
|
||||||
|
[setPlacement],
|
||||||
|
);
|
||||||
|
|
||||||
|
const valueOption = options.find((item) => item.value === value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef}>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={handleToggle}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
disabled={disabled}
|
||||||
|
className={classNames('dropdown-button', { active: isOpen })}
|
||||||
|
>
|
||||||
|
<span className='dropdown-button__label'>{valueOption?.text}</span>
|
||||||
|
<Icon id='down' icon={ArrowDropDownIcon} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Overlay
|
||||||
|
show={isOpen}
|
||||||
|
offset={[5, 5]}
|
||||||
|
placement={placement}
|
||||||
|
flip
|
||||||
|
target={containerRef}
|
||||||
|
popperConfig={{ strategy: 'fixed', onFirstUpdate: handleOverlayEnter }}
|
||||||
|
>
|
||||||
|
{({ props, placement }) => (
|
||||||
|
<div {...props}>
|
||||||
|
<div
|
||||||
|
className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}
|
||||||
|
>
|
||||||
|
<DropdownSelector
|
||||||
|
items={options}
|
||||||
|
value={value}
|
||||||
|
onClose={handleClose}
|
||||||
|
onChange={onChange}
|
||||||
|
classNamePrefix='privacy-dropdown'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Overlay>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: string;
|
||||||
|
options: SelectItem[];
|
||||||
|
disabled?: boolean;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SelectWithLabel: React.FC<PropsWithChildren<Props>> = ({
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
disabled,
|
||||||
|
children,
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<label className='app-form__toggle'>
|
||||||
|
<div className='app-form__toggle__label'>{children}</div>
|
||||||
|
|
||||||
|
<div className='app-form__toggle__toggle'>
|
||||||
|
<div>
|
||||||
|
<Dropdown
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
disabled={disabled}
|
||||||
|
options={options}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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 'flavours/glitch/actions/notifications';
|
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||||
|
import { openModal } from 'flavours/glitch/actions/modal';
|
||||||
|
import { fetchNotificationRequests, expandNotificationRequests, acceptNotificationRequests, dismissNotificationRequests } from 'flavours/glitch/actions/notifications';
|
||||||
import { changeSetting } from 'flavours/glitch/actions/settings';
|
import { changeSetting } from 'flavours/glitch/actions/settings';
|
||||||
|
import { CheckBox } from 'flavours/glitch/components/check_box';
|
||||||
import Column from 'flavours/glitch/components/column';
|
import Column from 'flavours/glitch/components/column';
|
||||||
import ColumnHeader from 'flavours/glitch/components/column_header';
|
import ColumnHeader from 'flavours/glitch/components/column_header';
|
||||||
import ScrollableList from 'flavours/glitch/components/scrollable_list';
|
import ScrollableList from 'flavours/glitch/components/scrollable_list';
|
||||||
|
import DropdownMenuContainer from 'flavours/glitch/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,124 @@ const ColumnSettings = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SelectRow = ({selectAllChecked, toggleSelectAll, selectedItems, selectionMode, setSelectionMode}) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
const notificationRequests = useSelector(state => state.getIn(['notificationRequests', 'items']));
|
||||||
|
|
||||||
|
const selectedCount = selectedItems.length;
|
||||||
|
|
||||||
|
const handleAcceptAll = useCallback(() => {
|
||||||
|
const items = notificationRequests.map(request => request.get('id')).toArray();
|
||||||
|
dispatch(openModal({
|
||||||
|
modalType: 'CONFIRM',
|
||||||
|
modalProps: {
|
||||||
|
title: intl.formatMessage(messages.confirmAcceptAllTitle),
|
||||||
|
message: intl.formatMessage(messages.confirmAcceptAllMessage, { count: items.length }),
|
||||||
|
confirm: intl.formatMessage(messages.confirmAcceptAllButton),
|
||||||
|
onConfirm: () =>
|
||||||
|
dispatch(acceptNotificationRequests(items)),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}, [dispatch, intl, notificationRequests]);
|
||||||
|
|
||||||
|
const handleDismissAll = useCallback(() => {
|
||||||
|
const items = notificationRequests.map(request => request.get('id')).toArray();
|
||||||
|
dispatch(openModal({
|
||||||
|
modalType: 'CONFIRM',
|
||||||
|
modalProps: {
|
||||||
|
title: intl.formatMessage(messages.confirmDismissAllTitle),
|
||||||
|
message: intl.formatMessage(messages.confirmDismissAllMessage, { count: items.length }),
|
||||||
|
confirm: intl.formatMessage(messages.confirmDismissAllButton),
|
||||||
|
onConfirm: () =>
|
||||||
|
dispatch(dismissNotificationRequests(items)),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}, [dispatch, intl, notificationRequests]);
|
||||||
|
|
||||||
|
const handleAcceptMultiple = 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 handleDismissMultiple = 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: handleAcceptMultiple },
|
||||||
|
{ text: intl.formatMessage(messages.dismissMultiple, { count: selectedCount }), action: handleDismissMultiple },
|
||||||
|
];
|
||||||
|
|
||||||
|
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 +196,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 +247,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 +269,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>
|
||||||
|
|
|
@ -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 'flavours/glitch/api_types/statuses';
|
import { me } from 'flavours/glitch/initial_state';
|
||||||
import type { NotificationGroupMention } from 'flavours/glitch/models/notification_group';
|
import type { NotificationGroupMention } from 'flavours/glitch/models/notification_group';
|
||||||
|
import type { Status } from 'flavours/glitch/models/status';
|
||||||
import { useAppSelector } from 'flavours/glitch/store';
|
import { useAppSelector } from 'flavours/glitch/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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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 'flavours/glitch/selectors/notifications';
|
} from 'flavours/glitch/selectors/notifications';
|
||||||
import {
|
import {
|
||||||
selectNeedsNotificationPermission,
|
selectNeedsNotificationPermission,
|
||||||
selectSettingsNotificationsExcludedTypes,
|
|
||||||
selectSettingsNotificationsQuickFilterActive,
|
|
||||||
selectSettingsNotificationsQuickFilterShow,
|
|
||||||
selectSettingsNotificationsShowUnread,
|
selectSettingsNotificationsShowUnread,
|
||||||
} from 'flavours/glitch/selectors/settings';
|
} from 'flavours/glitch/selectors/settings';
|
||||||
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
||||||
import type { RootState } from 'flavours/glitch/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';
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import { defineMessages, injectIntl } from 'react-intl';
|
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Helmet } from 'react-helmet';
|
import { Helmet } from 'react-helmet';
|
||||||
|
@ -19,6 +19,7 @@ import VisibilityIcon from '@/material-icons/400-24px/visibility.svg?react';
|
||||||
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
|
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
|
||||||
import { Icon } from 'flavours/glitch/components/icon';
|
import { Icon } from 'flavours/glitch/components/icon';
|
||||||
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
||||||
|
import { TimelineHint } from 'flavours/glitch/components/timeline_hint';
|
||||||
import ScrollContainer from 'flavours/glitch/containers/scroll_container';
|
import ScrollContainer from 'flavours/glitch/containers/scroll_container';
|
||||||
import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error';
|
import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error';
|
||||||
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
|
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
|
||||||
|
@ -622,7 +623,7 @@ class Status extends ImmutablePureComponent {
|
||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
let ancestors, descendants;
|
let ancestors, descendants, remoteHint;
|
||||||
const { isLoading, status, settings, ancestorsIds, descendantsIds, intl, domain, multiColumn, pictureInPicture } = this.props;
|
const { isLoading, status, settings, ancestorsIds, descendantsIds, intl, domain, multiColumn, pictureInPicture } = this.props;
|
||||||
const { fullscreen } = this.state;
|
const { fullscreen } = this.state;
|
||||||
|
|
||||||
|
@ -653,6 +654,10 @@ class Status extends ImmutablePureComponent {
|
||||||
const isLocal = status.getIn(['account', 'acct'], '').indexOf('@') === -1;
|
const isLocal = status.getIn(['account', 'acct'], '').indexOf('@') === -1;
|
||||||
const isIndexable = !status.getIn(['account', 'noindex']);
|
const isIndexable = !status.getIn(['account', 'noindex']);
|
||||||
|
|
||||||
|
if (!isLocal) {
|
||||||
|
remoteHint = <TimelineHint url={status.get('url')} resource={<FormattedMessage id='timeline_hint.resources.replies' defaultMessage='Some replies' />} />;
|
||||||
|
}
|
||||||
|
|
||||||
const handlers = {
|
const handlers = {
|
||||||
moveUp: this.handleHotkeyMoveUp,
|
moveUp: this.handleHotkeyMoveUp,
|
||||||
moveDown: this.handleHotkeyMoveDown,
|
moveDown: this.handleHotkeyMoveDown,
|
||||||
|
@ -724,6 +729,7 @@ class Status extends ImmutablePureComponent {
|
||||||
</HotKeys>
|
</HotKeys>
|
||||||
|
|
||||||
{descendants}
|
{descendants}
|
||||||
|
{remoteHint}
|
||||||
</div>
|
</div>
|
||||||
</ScrollContainer>
|
</ScrollContainer>
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,108 @@
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { useDispatch } from 'react-redux';
|
||||||
|
|
||||||
|
import InventoryIcon from '@/material-icons/400-24px/inventory_2.svg?react';
|
||||||
|
import PersonAlertIcon from '@/material-icons/400-24px/person_alert.svg?react';
|
||||||
|
import ShieldQuestionIcon from '@/material-icons/400-24px/shield_question.svg?react';
|
||||||
|
import { closeModal } from 'flavours/glitch/actions/modal';
|
||||||
|
import { updateNotificationsPolicy } from 'flavours/glitch/actions/notification_policies';
|
||||||
|
import { Button } from 'flavours/glitch/components/button';
|
||||||
|
import { Icon } from 'flavours/glitch/components/icon';
|
||||||
|
|
||||||
|
export const IgnoreNotificationsModal = ({ filterType }) => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
dispatch(closeModal({ modalType: undefined, ignoreFocus: false }));
|
||||||
|
void dispatch(updateNotificationsPolicy({ [filterType]: 'drop' }));
|
||||||
|
}, [dispatch, filterType]);
|
||||||
|
|
||||||
|
const handleSecondaryClick = useCallback(() => {
|
||||||
|
dispatch(closeModal({ modalType: undefined, ignoreFocus: false }));
|
||||||
|
void dispatch(updateNotificationsPolicy({ [filterType]: 'filter' }));
|
||||||
|
}, [dispatch, filterType]);
|
||||||
|
|
||||||
|
const handleCancel = useCallback(() => {
|
||||||
|
dispatch(closeModal({ modalType: undefined, ignoreFocus: false }));
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
let title = null;
|
||||||
|
|
||||||
|
switch(filterType) {
|
||||||
|
case 'for_not_following':
|
||||||
|
title = <FormattedMessage id='ignore_notifications_modal.not_following_title' defaultMessage="Ignore notifications from people you don't follow?" />;
|
||||||
|
break;
|
||||||
|
case 'for_not_followers':
|
||||||
|
title = <FormattedMessage id='ignore_notifications_modal.not_followers_title' defaultMessage='Ignore notifications from people not following you?' />;
|
||||||
|
break;
|
||||||
|
case 'for_new_accounts':
|
||||||
|
title = <FormattedMessage id='ignore_notifications_modal.new_accounts_title' defaultMessage='Ignore notifications from new accounts?' />;
|
||||||
|
break;
|
||||||
|
case 'for_private_mentions':
|
||||||
|
title = <FormattedMessage id='ignore_notifications_modal.private_mentions_title' defaultMessage='Ignore notifications from unsolicited Private Mentions?' />;
|
||||||
|
break;
|
||||||
|
case 'for_limited_accounts':
|
||||||
|
title = <FormattedMessage id='ignore_notifications_modal.limited_accounts_title' defaultMessage='Ignore notifications from moderated accounts?' />;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='modal-root__modal safety-action-modal'>
|
||||||
|
<div className='safety-action-modal__top'>
|
||||||
|
<div className='safety-action-modal__header'>
|
||||||
|
<h1>{title}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='safety-action-modal__bullet-points'>
|
||||||
|
<div>
|
||||||
|
<div className='safety-action-modal__bullet-points__icon'><Icon icon={InventoryIcon} /></div>
|
||||||
|
<div><FormattedMessage id='ignore_notifications_modal.filter_to_review_separately' defaultMessage='You can review filtered notifications speparately' /></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className='safety-action-modal__bullet-points__icon'><Icon icon={PersonAlertIcon} /></div>
|
||||||
|
<div><FormattedMessage id='ignore_notifications_modal.filter_to_act_users' defaultMessage="You'll still be able to accept, reject, or report users" /></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className='safety-action-modal__bullet-points__icon'><Icon icon={ShieldQuestionIcon} /></div>
|
||||||
|
<div><FormattedMessage id='ignore_notifications_modal.filter_to_avoid_confusion' defaultMessage='Filtering helps avoid potential confusion' /></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<FormattedMessage id='ignore_notifications_modal.disclaimer' defaultMessage="Mastodon cannot inform users that you've ignored their notifications. Ignoring notifications will not stop the messages themselves from being sent." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className='safety-action-modal__bottom'>
|
||||||
|
<div className='safety-action-modal__actions'>
|
||||||
|
<Button onClick={handleSecondaryClick} secondary>
|
||||||
|
<FormattedMessage id='ignore_notifications_modal.filter_instead' defaultMessage='Filter instead' />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className='spacer' />
|
||||||
|
|
||||||
|
<button onClick={handleCancel} className='link-button'>
|
||||||
|
<FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onClick={handleClick} className='link-button'>
|
||||||
|
<FormattedMessage id='ignore_notifications_modal.ignore' defaultMessage='Ignore notifications' />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
IgnoreNotificationsModal.propTypes = {
|
||||||
|
filterType: PropTypes.string.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default IgnoreNotificationsModal;
|
|
@ -19,6 +19,7 @@ import {
|
||||||
InteractionModal,
|
InteractionModal,
|
||||||
SubscribedLanguagesModal,
|
SubscribedLanguagesModal,
|
||||||
ClosedRegistrationsModal,
|
ClosedRegistrationsModal,
|
||||||
|
IgnoreNotificationsModal,
|
||||||
} from 'flavours/glitch/features/ui/util/async-components';
|
} from 'flavours/glitch/features/ui/util/async-components';
|
||||||
import { getScrollbarWidth } from 'flavours/glitch/utils/scrollbar';
|
import { getScrollbarWidth } from 'flavours/glitch/utils/scrollbar';
|
||||||
|
|
||||||
|
@ -80,6 +81,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 {
|
||||||
|
|
|
@ -146,6 +146,10 @@ export function SettingsModal () {
|
||||||
return import(/* webpackChunkName: "flavours/glitch/async/settings_modal" */'../../local_settings');
|
return import(/* webpackChunkName: "flavours/glitch/async/settings_modal" */'../../local_settings');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function IgnoreNotificationsModal () {
|
||||||
|
return import(/* webpackChunkName: "flavours/glitch/async/ignore_notifications_modal" */'../components/ignore_notifications_modal');
|
||||||
|
}
|
||||||
|
|
||||||
export function MediaGallery () {
|
export function MediaGallery () {
|
||||||
return import(/* webpackChunkName: "flavours/glitch/async/media_gallery" */'../../../components/media_gallery');
|
return import(/* webpackChunkName: "flavours/glitch/async/media_gallery" */'../../../components/media_gallery');
|
||||||
}
|
}
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -1,15 +1,62 @@
|
||||||
import { createSelector } from '@reduxjs/toolkit';
|
import { createSelector } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
import { compareId } from 'flavours/glitch/compare_id';
|
import { compareId } from 'flavours/glitch/compare_id';
|
||||||
|
import type { NotificationGroup } from 'flavours/glitch/models/notification_group';
|
||||||
|
import type { NotificationGap } from 'flavours/glitch/reducers/notification_groups';
|
||||||
import type { RootState } from 'flavours/glitch/store';
|
import type { RootState } from 'flavours/glitch/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,
|
||||||
);
|
);
|
||||||
|
|
|
@ -926,6 +926,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;
|
||||||
|
@ -2961,6 +2968,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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -4081,18 +4093,17 @@ input.glitch-setting-text {
|
||||||
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 {
|
||||||
|
@ -4514,6 +4525,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;
|
||||||
|
@ -4726,6 +4767,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 {
|
||||||
|
@ -6715,9 +6761,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;
|
||||||
|
@ -6725,7 +6772,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;
|
||||||
|
@ -6755,7 +6802,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 {
|
||||||
|
@ -6790,7 +6837,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 {
|
||||||
|
@ -6835,7 +6882,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;
|
||||||
|
@ -6843,13 +6890,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;
|
||||||
|
|
||||||
|
@ -6868,22 +6915,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;
|
||||||
}
|
}
|
||||||
|
@ -6904,16 +6948,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -8003,20 +8047,9 @@ img.modal-warning {
|
||||||
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 {
|
||||||
|
@ -8026,19 +8059,28 @@ img.modal-warning {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -8207,6 +8249,11 @@ noscript {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: $no-gap-breakpoint) {
|
||||||
|
border-left: 0;
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer__backdrop {
|
.drawer__backdrop {
|
||||||
|
@ -10761,12 +10808,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;
|
||||||
|
@ -10824,6 +10887,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 {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -620,3 +614,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%);
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
|
@ -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 };
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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,124 @@ const ColumnSettings = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SelectRow = ({selectAllChecked, toggleSelectAll, selectedItems, selectionMode, setSelectionMode}) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
const notificationRequests = useSelector(state => state.getIn(['notificationRequests', 'items']));
|
||||||
|
|
||||||
|
const selectedCount = selectedItems.length;
|
||||||
|
|
||||||
|
const handleAcceptAll = useCallback(() => {
|
||||||
|
const items = notificationRequests.map(request => request.get('id')).toArray();
|
||||||
|
dispatch(openModal({
|
||||||
|
modalType: 'CONFIRM',
|
||||||
|
modalProps: {
|
||||||
|
title: intl.formatMessage(messages.confirmAcceptAllTitle),
|
||||||
|
message: intl.formatMessage(messages.confirmAcceptAllMessage, { count: items.length }),
|
||||||
|
confirm: intl.formatMessage(messages.confirmAcceptAllButton),
|
||||||
|
onConfirm: () =>
|
||||||
|
dispatch(acceptNotificationRequests(items)),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}, [dispatch, intl, notificationRequests]);
|
||||||
|
|
||||||
|
const handleDismissAll = useCallback(() => {
|
||||||
|
const items = notificationRequests.map(request => request.get('id')).toArray();
|
||||||
|
dispatch(openModal({
|
||||||
|
modalType: 'CONFIRM',
|
||||||
|
modalProps: {
|
||||||
|
title: intl.formatMessage(messages.confirmDismissAllTitle),
|
||||||
|
message: intl.formatMessage(messages.confirmDismissAllMessage, { count: items.length }),
|
||||||
|
confirm: intl.formatMessage(messages.confirmDismissAllButton),
|
||||||
|
onConfirm: () =>
|
||||||
|
dispatch(dismissNotificationRequests(items)),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}, [dispatch, intl, notificationRequests]);
|
||||||
|
|
||||||
|
const handleAcceptMultiple = 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 handleDismissMultiple = 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: handleAcceptMultiple },
|
||||||
|
{ text: intl.formatMessage(messages.dismissMultiple, { count: selectedCount }), action: handleDismissMultiple },
|
||||||
|
];
|
||||||
|
|
||||||
|
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 +196,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 +247,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 +269,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>
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import { defineMessages, injectIntl } from 'react-intl';
|
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Helmet } from 'react-helmet';
|
import { Helmet } from 'react-helmet';
|
||||||
|
@ -18,6 +18,7 @@ import VisibilityIcon from '@/material-icons/400-24px/visibility.svg?react';
|
||||||
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
|
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||||
|
import { TimelineHint } from 'mastodon/components/timeline_hint';
|
||||||
import ScrollContainer from 'mastodon/containers/scroll_container';
|
import ScrollContainer from 'mastodon/containers/scroll_container';
|
||||||
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
|
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
|
||||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||||
|
@ -598,7 +599,7 @@ class Status extends ImmutablePureComponent {
|
||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
let ancestors, descendants;
|
let ancestors, descendants, remoteHint;
|
||||||
const { isLoading, status, ancestorsIds, descendantsIds, intl, domain, multiColumn, pictureInPicture } = this.props;
|
const { isLoading, status, ancestorsIds, descendantsIds, intl, domain, multiColumn, pictureInPicture } = this.props;
|
||||||
const { fullscreen } = this.state;
|
const { fullscreen } = this.state;
|
||||||
|
|
||||||
|
@ -627,6 +628,10 @@ class Status extends ImmutablePureComponent {
|
||||||
const isLocal = status.getIn(['account', 'acct'], '').indexOf('@') === -1;
|
const isLocal = status.getIn(['account', 'acct'], '').indexOf('@') === -1;
|
||||||
const isIndexable = !status.getIn(['account', 'noindex']);
|
const isIndexable = !status.getIn(['account', 'noindex']);
|
||||||
|
|
||||||
|
if (!isLocal) {
|
||||||
|
remoteHint = <TimelineHint url={status.get('url')} resource={<FormattedMessage id='timeline_hint.resources.replies' defaultMessage='Some replies' />} />;
|
||||||
|
}
|
||||||
|
|
||||||
const handlers = {
|
const handlers = {
|
||||||
moveUp: this.handleHotkeyMoveUp,
|
moveUp: this.handleHotkeyMoveUp,
|
||||||
moveDown: this.handleHotkeyMoveDown,
|
moveDown: this.handleHotkeyMoveDown,
|
||||||
|
@ -695,6 +700,7 @@ class Status extends ImmutablePureComponent {
|
||||||
</HotKeys>
|
</HotKeys>
|
||||||
|
|
||||||
{descendants}
|
{descendants}
|
||||||
|
{remoteHint}
|
||||||
</div>
|
</div>
|
||||||
</ScrollContainer>
|
</ScrollContainer>
|
||||||
|
|
||||||
|
|
|
@ -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;
|
|
@ -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 {
|
||||||
|
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
|
@ -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",
|
||||||
|
@ -798,6 +831,7 @@
|
||||||
"timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
|
"timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
|
||||||
"timeline_hint.resources.followers": "Followers",
|
"timeline_hint.resources.followers": "Followers",
|
||||||
"timeline_hint.resources.follows": "Follows",
|
"timeline_hint.resources.follows": "Follows",
|
||||||
|
"timeline_hint.resources.replies": "Some replies",
|
||||||
"timeline_hint.resources.statuses": "Older posts",
|
"timeline_hint.resources.statuses": "Older posts",
|
||||||
"trends.counter_by_accounts": "{count, plural, one {{counter} person} other {{counter} people}} in the past {days, plural, one {day} other {{days} days}}",
|
"trends.counter_by_accounts": "{count, plural, one {{counter} person} other {{counter} people}} in the past {days, plural, one {day} other {{days} days}}",
|
||||||
"trends.trending_now": "Trending now",
|
"trends.trending_now": "Trending now",
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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,
|
||||||
);
|
);
|
||||||
|
|
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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%);
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -4,17 +4,25 @@
|
||||||
#
|
#
|
||||||
# Table name: notification_policies
|
# Table name: notification_policies
|
||||||
#
|
#
|
||||||
# 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
|
# created_at :datetime not null
|
||||||
# filter_not_followers :boolean default(FALSE), not null
|
# updated_at :datetime not null
|
||||||
# filter_new_accounts :boolean default(FALSE), not null
|
# for_not_following :integer default("accept"), not null
|
||||||
# filter_private_mentions :boolean default(TRUE), not null
|
# for_not_followers :integer default("accept"), not null
|
||||||
# created_at :datetime not null
|
# for_new_accounts :integer default("accept"), not null
|
||||||
# updated_at :datetime 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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
@ -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?
|
||||||
|
|
|
@ -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?
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
11
db/schema.rb
11
db/schema.rb
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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
|
|
@ -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
|
||||||
|
|
|
@ -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',
|
||||||
|
|
60
yarn.lock
60
yarn.lock
|
@ -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"
|
||||||
|
|
Loading…
Reference in New Issue