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
|
||||
|
||||
def show
|
||||
render json: @policy, serializer: REST::NotificationPolicySerializer
|
||||
render json: @policy, serializer: REST::V1::NotificationPolicySerializer
|
||||
end
|
||||
|
||||
def update
|
||||
@policy.update!(resource_params)
|
||||
render json: @policy, serializer: REST::NotificationPolicySerializer
|
||||
render json: @policy, serializer: REST::V1::NotificationPolicySerializer
|
||||
end
|
||||
|
||||
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_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_SUCCESS = 'NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS';
|
||||
export const NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL = 'NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL';
|
||||
|
@ -584,6 +592,62 @@ export const dismissNotificationRequestFail = (id, 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) => {
|
||||
const current = getState().getIn(['notificationRequests', 'current']);
|
||||
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';
|
||||
|
||||
export const apiGetNotificationPolicy = () =>
|
||||
apiRequestGet<NotificationPolicyJSON>('/v1/notifications/policy');
|
||||
apiRequestGet<NotificationPolicyJSON>('/v2/notifications/policy');
|
||||
|
||||
export const apiUpdateNotificationsPolicy = (
|
||||
policy: Partial<NotificationPolicyJSON>,
|
||||
) => apiRequestPut<NotificationPolicyJSON>('/v1/notifications/policy', policy);
|
||||
) => apiRequestPut<NotificationPolicyJSON>('/v2/notifications/policy', policy);
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
// See app/serializers/rest/notification_policy_serializer.rb
|
||||
|
||||
export type NotificationPolicyValue = 'accept' | 'filter' | 'drop';
|
||||
|
||||
export interface NotificationPolicyJSON {
|
||||
filter_not_following: boolean;
|
||||
filter_not_followers: boolean;
|
||||
filter_new_accounts: boolean;
|
||||
filter_private_mentions: boolean;
|
||||
for_not_following: NotificationPolicyValue;
|
||||
for_not_followers: NotificationPolicyValue;
|
||||
for_new_accounts: NotificationPolicyValue;
|
||||
for_private_mentions: NotificationPolicyValue;
|
||||
for_limited_accounts: NotificationPolicyValue;
|
||||
summary: {
|
||||
pending_requests_count: number;
|
||||
pending_notifications_count: number;
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
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 { Icon } from './icon';
|
||||
|
@ -7,6 +8,7 @@ import { Icon } from './icon';
|
|||
interface Props {
|
||||
value: string;
|
||||
checked: boolean;
|
||||
indeterminate: boolean;
|
||||
name: string;
|
||||
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
label: React.ReactNode;
|
||||
|
@ -16,6 +18,7 @@ export const CheckBox: React.FC<Props> = ({
|
|||
name,
|
||||
value,
|
||||
checked,
|
||||
indeterminate,
|
||||
onChange,
|
||||
label,
|
||||
}) => {
|
||||
|
@ -29,8 +32,14 @@ export const CheckBox: React.FC<Props> = ({
|
|||
onChange={onChange}
|
||||
/>
|
||||
|
||||
<span className={classNames('check-box__input', { checked })}>
|
||||
{checked && <Icon id='check' icon={DoneIcon} />}
|
||||
<span
|
||||
className={classNames('check-box__input', { checked, indeterminate })}
|
||||
>
|
||||
{indeterminate ? (
|
||||
<Icon id='indeterminate' icon={CheckIndeterminateSmallIcon} />
|
||||
) : (
|
||||
checked && <Icon id='check' icon={DoneIcon} />
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span>{label}</span>
|
||||
|
|
|
@ -13,7 +13,7 @@ const listenerOptions = supportsPassiveEvents
|
|||
? { passive: true, capture: true }
|
||||
: true;
|
||||
|
||||
interface SelectItem {
|
||||
export interface SelectItem {
|
||||
value: string;
|
||||
icon?: string;
|
||||
iconComponent?: IconProp;
|
||||
|
|
|
@ -3,15 +3,21 @@ import { useCallback } from 'react';
|
|||
|
||||
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 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 { initReport } from 'flavours/glitch/actions/reports';
|
||||
import { Avatar } from 'flavours/glitch/components/avatar';
|
||||
import { CheckBox } from 'flavours/glitch/components/check_box';
|
||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
||||
import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
|
||||
import { makeGetAccount } from 'flavours/glitch/selectors';
|
||||
import { toCappedNumber } from 'flavours/glitch/utils/numbers';
|
||||
|
||||
|
@ -20,12 +26,18 @@ const getAccount = makeGetAccount();
|
|||
const messages = defineMessages({
|
||||
accept: { id: 'notification_requests.accept', defaultMessage: 'Accept' },
|
||||
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 account = useSelector(state => getAccount(state, accountId));
|
||||
const intl = useIntl();
|
||||
const { push: historyPush } = useHistory();
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
dispatch(dismissNotificationRequest(id));
|
||||
|
@ -35,9 +47,51 @@ export const NotificationRequest = ({ id, accountId, notificationsCount }) => {
|
|||
dispatch(acceptNotificationRequest(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 (
|
||||
<div className='notification-request'>
|
||||
<Link to={`/notifications/requests/${id}`} className='notification-request__link'>
|
||||
/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- this is just a minor affordance, but we will need a comprehensive accessibility pass */
|
||||
<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)} />
|
||||
|
||||
<div className='notification-request__name'>
|
||||
|
@ -51,7 +105,13 @@ export const NotificationRequest = ({ id, accountId, notificationsCount }) => {
|
|||
|
||||
<div className='notification-request__actions'>
|
||||
<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>
|
||||
);
|
||||
|
@ -61,4 +121,7 @@ NotificationRequest.propTypes = {
|
|||
id: PropTypes.string.isRequired,
|
||||
accountId: 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 { FormattedMessage } from 'react-intl';
|
||||
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { openModal } from 'flavours/glitch/actions/modal';
|
||||
import { updateNotificationsPolicy } from 'flavours/glitch/actions/notification_policies';
|
||||
import type { AppDispatch } from 'flavours/glitch/store';
|
||||
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
|
||||
|
||||
import { CheckboxWithLabel } from './checkbox_with_label';
|
||||
import { SelectWithLabel } from './select_with_label';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
const noop = () => {};
|
||||
const messages = defineMessages({
|
||||
accept: { id: 'notifications.policy.accept', defaultMessage: 'Accept' },
|
||||
accept_hint: {
|
||||
id: 'notifications.policy.accept_hint',
|
||||
defaultMessage: 'Show in notifications',
|
||||
},
|
||||
filter: { id: 'notifications.policy.filter', defaultMessage: 'Filter' },
|
||||
filter_hint: {
|
||||
id: 'notifications.policy.filter_hint',
|
||||
defaultMessage: 'Send to filtered notifications inbox',
|
||||
},
|
||||
drop: { id: 'notifications.policy.drop', defaultMessage: 'Ignore' },
|
||||
drop_hint: {
|
||||
id: 'notifications.policy.drop_hint',
|
||||
defaultMessage: 'Send to the void, never to be seen again',
|
||||
},
|
||||
});
|
||||
|
||||
// TODO: change the following when we change the API
|
||||
const changeFilter = (
|
||||
dispatch: AppDispatch,
|
||||
filterType: string,
|
||||
value: string,
|
||||
) => {
|
||||
if (value === 'drop') {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'IGNORE_NOTIFICATIONS',
|
||||
modalProps: { filterType },
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
void dispatch(updateNotificationsPolicy({ [filterType]: value }));
|
||||
}
|
||||
};
|
||||
|
||||
export const PolicyControls: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const notificationPolicy = useAppSelector(
|
||||
|
@ -18,56 +54,74 @@ export const PolicyControls: React.FC = () => {
|
|||
);
|
||||
|
||||
const handleFilterNotFollowing = useCallback(
|
||||
(checked: boolean) => {
|
||||
void dispatch(
|
||||
updateNotificationsPolicy({ filter_not_following: checked }),
|
||||
);
|
||||
(value: string) => {
|
||||
changeFilter(dispatch, 'for_not_following', value);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleFilterNotFollowers = useCallback(
|
||||
(checked: boolean) => {
|
||||
void dispatch(
|
||||
updateNotificationsPolicy({ filter_not_followers: checked }),
|
||||
);
|
||||
(value: string) => {
|
||||
changeFilter(dispatch, 'for_not_followers', value);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleFilterNewAccounts = useCallback(
|
||||
(checked: boolean) => {
|
||||
void dispatch(
|
||||
updateNotificationsPolicy({ filter_new_accounts: checked }),
|
||||
);
|
||||
(value: string) => {
|
||||
changeFilter(dispatch, 'for_new_accounts', value);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleFilterPrivateMentions = useCallback(
|
||||
(checked: boolean) => {
|
||||
void dispatch(
|
||||
updateNotificationsPolicy({ filter_private_mentions: checked }),
|
||||
);
|
||||
(value: string) => {
|
||||
changeFilter(dispatch, 'for_private_mentions', value);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleFilterLimitedAccounts = useCallback(
|
||||
(value: string) => {
|
||||
changeFilter(dispatch, 'for_limited_accounts', value);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
if (!notificationPolicy) return null;
|
||||
|
||||
const options = [
|
||||
{
|
||||
value: 'accept',
|
||||
text: intl.formatMessage(messages.accept),
|
||||
meta: intl.formatMessage(messages.accept_hint),
|
||||
},
|
||||
{
|
||||
value: 'filter',
|
||||
text: intl.formatMessage(messages.filter),
|
||||
meta: intl.formatMessage(messages.filter_hint),
|
||||
},
|
||||
{
|
||||
value: 'drop',
|
||||
text: intl.formatMessage(messages.drop),
|
||||
meta: intl.formatMessage(messages.drop_hint),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h3>
|
||||
<FormattedMessage
|
||||
id='notifications.policy.title'
|
||||
defaultMessage='Filter out notifications from…'
|
||||
defaultMessage='Manage notifications from…'
|
||||
/>
|
||||
</h3>
|
||||
|
||||
<div className='column-settings__row'>
|
||||
<CheckboxWithLabel
|
||||
checked={notificationPolicy.filter_not_following}
|
||||
<SelectWithLabel
|
||||
value={notificationPolicy.for_not_following}
|
||||
onChange={handleFilterNotFollowing}
|
||||
options={options}
|
||||
>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
|
@ -81,11 +135,12 @@ export const PolicyControls: React.FC = () => {
|
|||
defaultMessage='Until you manually approve them'
|
||||
/>
|
||||
</span>
|
||||
</CheckboxWithLabel>
|
||||
</SelectWithLabel>
|
||||
|
||||
<CheckboxWithLabel
|
||||
checked={notificationPolicy.filter_not_followers}
|
||||
<SelectWithLabel
|
||||
value={notificationPolicy.for_not_followers}
|
||||
onChange={handleFilterNotFollowers}
|
||||
options={options}
|
||||
>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
|
@ -100,11 +155,12 @@ export const PolicyControls: React.FC = () => {
|
|||
values={{ days: 3 }}
|
||||
/>
|
||||
</span>
|
||||
</CheckboxWithLabel>
|
||||
</SelectWithLabel>
|
||||
|
||||
<CheckboxWithLabel
|
||||
checked={notificationPolicy.filter_new_accounts}
|
||||
<SelectWithLabel
|
||||
value={notificationPolicy.for_new_accounts}
|
||||
onChange={handleFilterNewAccounts}
|
||||
options={options}
|
||||
>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
|
@ -119,11 +175,12 @@ export const PolicyControls: React.FC = () => {
|
|||
values={{ days: 30 }}
|
||||
/>
|
||||
</span>
|
||||
</CheckboxWithLabel>
|
||||
</SelectWithLabel>
|
||||
|
||||
<CheckboxWithLabel
|
||||
checked={notificationPolicy.filter_private_mentions}
|
||||
<SelectWithLabel
|
||||
value={notificationPolicy.for_private_mentions}
|
||||
onChange={handleFilterPrivateMentions}
|
||||
options={options}
|
||||
>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
|
@ -137,9 +194,13 @@ export const PolicyControls: React.FC = () => {
|
|||
defaultMessage="Filtered unless it's in reply to your own mention or if you follow the sender"
|
||||
/>
|
||||
</span>
|
||||
</CheckboxWithLabel>
|
||||
</SelectWithLabel>
|
||||
|
||||
<CheckboxWithLabel checked disabled onChange={noop}>
|
||||
<SelectWithLabel
|
||||
value={notificationPolicy.for_limited_accounts}
|
||||
onChange={handleFilterLimitedAccounts}
|
||||
options={options}
|
||||
>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
id='notifications.policy.filter_limited_accounts_title'
|
||||
|
@ -152,7 +213,7 @@ export const PolicyControls: React.FC = () => {
|
|||
defaultMessage='Limited by server moderators'
|
||||
/>
|
||||
</span>
|
||||
</CheckboxWithLabel>
|
||||
</SelectWithLabel>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
|
|
@ -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 { useRef, useCallback, useEffect } from 'react';
|
||||
import { useRef, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
|
@ -8,11 +8,15 @@ import { Helmet } from 'react-helmet';
|
|||
import { useSelector, useDispatch } from 'react-redux';
|
||||
|
||||
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 { CheckBox } from 'flavours/glitch/components/check_box';
|
||||
import Column from 'flavours/glitch/components/column';
|
||||
import ColumnHeader from 'flavours/glitch/components/column_header';
|
||||
import ScrollableList from 'flavours/glitch/components/scrollable_list';
|
||||
import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
|
||||
|
||||
import { NotificationRequest } from './components/notification_request';
|
||||
import { PolicyControls } from './components/policy_controls';
|
||||
|
@ -20,7 +24,18 @@ import SettingToggle from './components/setting_toggle';
|
|||
|
||||
const messages = defineMessages({
|
||||
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 = () => {
|
||||
|
@ -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 }) => {
|
||||
const columnRef = useRef();
|
||||
const intl = useIntl();
|
||||
|
@ -63,10 +196,40 @@ export const NotificationRequests = ({ multiColumn }) => {
|
|||
const notificationRequests = useSelector(state => state.getIn(['notificationRequests', 'items']));
|
||||
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(() => {
|
||||
columnRef.current?.scrollTop();
|
||||
}, [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(() => {
|
||||
dispatch(expandNotificationRequests());
|
||||
}, [dispatch]);
|
||||
|
@ -84,6 +247,8 @@ export const NotificationRequests = ({ multiColumn }) => {
|
|||
onClick={handleHeaderClick}
|
||||
multiColumn={multiColumn}
|
||||
showBackButton
|
||||
appendContent={
|
||||
<SelectRow selectionMode={selectionMode} setSelectionMode={setSelectionMode} selectAllChecked={selectAllChecked} toggleSelectAll={toggleSelectAll} selectedItems={checkedRequestIds} />}
|
||||
>
|
||||
<ColumnSettings />
|
||||
</ColumnHeader>
|
||||
|
@ -104,6 +269,9 @@ export const NotificationRequests = ({ multiColumn }) => {
|
|||
id={request.get('id')}
|
||||
accountId={request.get('account')}
|
||||
notificationsCount={request.get('notifications_count')}
|
||||
showCheckbox={selectionMode}
|
||||
checked={checkedRequestIds.includes(request.get('id'))}
|
||||
toggleCheck={handleCheck}
|
||||
/>
|
||||
))}
|
||||
</ScrollableList>
|
||||
|
|
|
@ -2,26 +2,33 @@ import { FormattedMessage } from 'react-intl';
|
|||
|
||||
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.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 { Status } from 'flavours/glitch/models/status';
|
||||
import { useAppSelector } from 'flavours/glitch/store';
|
||||
|
||||
import type { LabelRenderer } from './notification_group_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
|
||||
id='notification.mention'
|
||||
defaultMessage='{name} mentioned you'
|
||||
values={values}
|
||||
id='notification.label.private_mention'
|
||||
defaultMessage='Private mention'
|
||||
/>
|
||||
);
|
||||
|
||||
const privateMentionLabelRenderer: LabelRenderer = (values) => (
|
||||
const replyLabelRenderer: LabelRenderer = () => (
|
||||
<FormattedMessage id='notification.label.reply' defaultMessage='Reply' />
|
||||
);
|
||||
|
||||
const privateReplyLabelRenderer: LabelRenderer = () => (
|
||||
<FormattedMessage
|
||||
id='notification.private_mention'
|
||||
defaultMessage='{name} privately mentioned you'
|
||||
values={values}
|
||||
id='notification.label.private_reply'
|
||||
defaultMessage='Private reply'
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -29,27 +36,30 @@ export const NotificationMention: React.FC<{
|
|||
notification: NotificationGroupMention;
|
||||
unread: boolean;
|
||||
}> = ({ notification, unread }) => {
|
||||
const statusVisibility = useAppSelector(
|
||||
(state) =>
|
||||
state.statuses.getIn([
|
||||
notification.statusId,
|
||||
'visibility',
|
||||
]) as StatusVisibility,
|
||||
);
|
||||
const [isDirect, isReply] = useAppSelector((state) => {
|
||||
const status = state.statuses.get(notification.statusId) as Status;
|
||||
|
||||
return [
|
||||
status.get('visibility') === 'direct',
|
||||
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 (
|
||||
<NotificationWithStatus
|
||||
type='mention'
|
||||
icon={statusVisibility === 'direct' ? AlternateEmailIcon : ReplyIcon}
|
||||
icon={isReply ? ReplyIcon : AlternateEmailIcon}
|
||||
iconId='reply'
|
||||
accountIds={notification.sampleAccountIds}
|
||||
count={notification.notifications_count}
|
||||
statusId={notification.statusId}
|
||||
labelRenderer={
|
||||
statusVisibility === 'direct'
|
||||
? privateMentionLabelRenderer
|
||||
: labelRenderer
|
||||
}
|
||||
labelRenderer={labelRenderer}
|
||||
unread={unread}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -4,8 +4,6 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
|||
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
import DoneAllIcon from '@/material-icons/400-24px/done_all.svg?react';
|
||||
|
@ -27,16 +25,13 @@ import {
|
|||
selectUnreadNotificationGroupsCount,
|
||||
selectPendingNotificationGroupsCount,
|
||||
selectAnyPendingNotification,
|
||||
selectNotificationGroups,
|
||||
} from 'flavours/glitch/selectors/notifications';
|
||||
import {
|
||||
selectNeedsNotificationPermission,
|
||||
selectSettingsNotificationsExcludedTypes,
|
||||
selectSettingsNotificationsQuickFilterActive,
|
||||
selectSettingsNotificationsQuickFilterShow,
|
||||
selectSettingsNotificationsShowUnread,
|
||||
} from 'flavours/glitch/selectors/settings';
|
||||
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
||||
import type { RootState } from 'flavours/glitch/store';
|
||||
|
||||
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
||||
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<{
|
||||
columnId?: string;
|
||||
multiColumn?: boolean;
|
||||
}> = ({ columnId, multiColumn }) => {
|
||||
const intl = useIntl();
|
||||
const notifications = useAppSelector(getNotifications);
|
||||
const notifications = useAppSelector(selectNotificationGroups);
|
||||
const dispatch = useAppDispatch();
|
||||
const isLoading = useAppSelector((s) => s.notificationGroups.isLoading);
|
||||
const hasMore = notifications.at(-1)?.type === 'gap';
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
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 { Icon } from 'flavours/glitch/components/icon';
|
||||
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 BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error';
|
||||
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
|
||||
|
@ -622,7 +623,7 @@ class Status extends ImmutablePureComponent {
|
|||
};
|
||||
|
||||
render () {
|
||||
let ancestors, descendants;
|
||||
let ancestors, descendants, remoteHint;
|
||||
const { isLoading, status, settings, ancestorsIds, descendantsIds, intl, domain, multiColumn, pictureInPicture } = this.props;
|
||||
const { fullscreen } = this.state;
|
||||
|
||||
|
@ -653,6 +654,10 @@ class Status extends ImmutablePureComponent {
|
|||
const isLocal = status.getIn(['account', 'acct'], '').indexOf('@') === -1;
|
||||
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 = {
|
||||
moveUp: this.handleHotkeyMoveUp,
|
||||
moveDown: this.handleHotkeyMoveDown,
|
||||
|
@ -724,6 +729,7 @@ class Status extends ImmutablePureComponent {
|
|||
</HotKeys>
|
||||
|
||||
{descendants}
|
||||
{remoteHint}
|
||||
</div>
|
||||
</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,
|
||||
SubscribedLanguagesModal,
|
||||
ClosedRegistrationsModal,
|
||||
IgnoreNotificationsModal,
|
||||
} from 'flavours/glitch/features/ui/util/async-components';
|
||||
import { getScrollbarWidth } from 'flavours/glitch/utils/scrollbar';
|
||||
|
||||
|
@ -80,6 +81,7 @@ export const MODAL_COMPONENTS = {
|
|||
'SUBSCRIBED_LANGUAGES': SubscribedLanguagesModal,
|
||||
'INTERACTION': InteractionModal,
|
||||
'CLOSED_REGISTRATIONS': ClosedRegistrationsModal,
|
||||
'IGNORE_NOTIFICATIONS': IgnoreNotificationsModal,
|
||||
};
|
||||
|
||||
export default class ModalRoot extends PureComponent {
|
||||
|
|
|
@ -146,6 +146,10 @@ export function SettingsModal () {
|
|||
return import(/* webpackChunkName: "flavours/glitch/async/settings_modal" */'../../local_settings');
|
||||
}
|
||||
|
||||
export function IgnoreNotificationsModal () {
|
||||
return import(/* webpackChunkName: "flavours/glitch/async/ignore_notifications_modal" */'../components/ignore_notifications_modal');
|
||||
}
|
||||
|
||||
export function MediaGallery () {
|
||||
return import(/* webpackChunkName: "flavours/glitch/async/media_gallery" */'../../../components/media_gallery');
|
||||
}
|
||||
|
|
|
@ -13,6 +13,8 @@ import {
|
|||
NOTIFICATION_REQUEST_FETCH_FAIL,
|
||||
NOTIFICATION_REQUEST_ACCEPT_REQUEST,
|
||||
NOTIFICATION_REQUEST_DISMISS_REQUEST,
|
||||
NOTIFICATION_REQUESTS_ACCEPT_REQUEST,
|
||||
NOTIFICATION_REQUESTS_DISMISS_REQUEST,
|
||||
NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST,
|
||||
NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS,
|
||||
NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL,
|
||||
|
@ -83,6 +85,9 @@ export const notificationRequestsReducer = (state = initialState, action) => {
|
|||
case NOTIFICATION_REQUEST_ACCEPT_REQUEST:
|
||||
case NOTIFICATION_REQUEST_DISMISS_REQUEST:
|
||||
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:
|
||||
return removeRequestByAccount(state, action.payload.relationship.id);
|
||||
case muteAccountSuccess.type:
|
||||
|
|
|
@ -1,15 +1,62 @@
|
|||
import { createSelector } from '@reduxjs/toolkit';
|
||||
|
||||
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 {
|
||||
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(
|
||||
[
|
||||
(s: RootState) => s.notificationGroups.lastReadId,
|
||||
(s: RootState) => s.notificationGroups.pendingGroups,
|
||||
(s: RootState) => s.notificationGroups.groups,
|
||||
selectNotificationGroups,
|
||||
selectPendingNotificationGroups,
|
||||
],
|
||||
(notificationMarker, pendingGroups, groups) => {
|
||||
(notificationMarker, groups, pendingGroups) => {
|
||||
return (
|
||||
groups.filter(
|
||||
(group) =>
|
||||
|
@ -31,7 +78,7 @@ export const selectUnreadNotificationGroupsCount = createSelector(
|
|||
export const selectAnyPendingNotification = createSelector(
|
||||
[
|
||||
(s: RootState) => s.notificationGroups.readMarkerId,
|
||||
(s: RootState) => s.notificationGroups.groups,
|
||||
selectNotificationGroups,
|
||||
],
|
||||
(notificationMarker, groups) => {
|
||||
return groups.some(
|
||||
|
@ -44,7 +91,7 @@ export const selectAnyPendingNotification = createSelector(
|
|||
);
|
||||
|
||||
export const selectPendingNotificationGroupsCount = createSelector(
|
||||
[(s: RootState) => s.notificationGroups.pendingGroups],
|
||||
[selectPendingNotificationGroups],
|
||||
(pendingGroups) =>
|
||||
pendingGroups.filter((group) => group.type !== 'gap').length,
|
||||
);
|
||||
|
|
|
@ -926,6 +926,13 @@ body > [data-popper-placement] {
|
|||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&[disabled] {
|
||||
cursor: default;
|
||||
color: $highlight-text-color;
|
||||
border-color: $highlight-text-color;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
|
@ -2961,6 +2968,11 @@ $ui-header-logo-wordmark-width: 99px;
|
|||
&.privacy-policy {
|
||||
border-top: 1px solid var(--background-border-color);
|
||||
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;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
color: $inverted-text-color;
|
||||
background: $white;
|
||||
color: $primary-text-color;
|
||||
background: $ui-base-color;
|
||||
padding: 7px 10px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid $white;
|
||||
border: 1px solid var(--background-border-color);
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
border-color: lighten($ui-highlight-color, 12%);
|
||||
}
|
||||
|
||||
&__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 {
|
||||
display: flex;
|
||||
font-size: 16px;
|
||||
|
@ -4726,6 +4767,11 @@ a.status-card {
|
|||
.column-header__collapsible-inner {
|
||||
border: 1px solid var(--background-border-color);
|
||||
border-top: 0;
|
||||
|
||||
@media screen and (max-width: $no-gap-breakpoint) {
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.column-header__setting-btn {
|
||||
|
@ -6715,9 +6761,10 @@ a.status-card {
|
|||
max-width: 90vw;
|
||||
width: 480px;
|
||||
height: 80vh;
|
||||
background: lighten($ui-secondary-color, 8%);
|
||||
color: $inverted-text-color;
|
||||
border-radius: 8px;
|
||||
background: var(--background-color);
|
||||
color: $primary-text-color;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--background-border-color);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
|
@ -6725,7 +6772,7 @@ a.status-card {
|
|||
|
||||
&__container {
|
||||
box-sizing: border-box;
|
||||
border-top: 1px solid $ui-secondary-color;
|
||||
border-top: 1px solid var(--background-border-color);
|
||||
padding: 20px;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
|
@ -6755,7 +6802,7 @@ a.status-card {
|
|||
&__lead {
|
||||
font-size: 17px;
|
||||
line-height: 22px;
|
||||
color: lighten($inverted-text-color, 16%);
|
||||
color: $secondary-text-color;
|
||||
margin-bottom: 30px;
|
||||
|
||||
a {
|
||||
|
@ -6790,7 +6837,7 @@ a.status-card {
|
|||
|
||||
.status__content,
|
||||
.status__content p {
|
||||
color: $inverted-text-color;
|
||||
color: $primary-text-color;
|
||||
}
|
||||
|
||||
.status__content__spoiler-link {
|
||||
|
@ -6835,7 +6882,7 @@ a.status-card {
|
|||
.poll__option.dialog-option {
|
||||
padding: 15px 0;
|
||||
flex: 0 0 auto;
|
||||
border-bottom: 1px solid $ui-secondary-color;
|
||||
border-bottom: 1px solid var(--background-border-color);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
|
@ -6843,13 +6890,13 @@ a.status-card {
|
|||
|
||||
& > .poll__option__text {
|
||||
font-size: 13px;
|
||||
color: lighten($inverted-text-color, 16%);
|
||||
color: $secondary-text-color;
|
||||
|
||||
strong {
|
||||
font-size: 17px;
|
||||
font-weight: 500;
|
||||
line-height: 22px;
|
||||
color: $inverted-text-color;
|
||||
color: $primary-text-color;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
|
||||
|
@ -6868,22 +6915,19 @@ a.status-card {
|
|||
display: block;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
color: $inverted-text-color;
|
||||
background: $simple-background-color;
|
||||
color: $primary-text-color;
|
||||
background: $ui-base-color;
|
||||
padding: 10px;
|
||||
font-family: inherit;
|
||||
font-size: 17px;
|
||||
line-height: 22px;
|
||||
resize: vertical;
|
||||
border: 0;
|
||||
border: 1px solid var(--background-border-color);
|
||||
outline: 0;
|
||||
border-radius: 4px;
|
||||
margin: 20px 0;
|
||||
|
||||
&::placeholder {
|
||||
color: $dark-text-color;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
}
|
||||
|
@ -6904,16 +6948,16 @@ a.status-card {
|
|||
}
|
||||
|
||||
.button.button-secondary {
|
||||
border-color: $inverted-text-color;
|
||||
color: $inverted-text-color;
|
||||
border-color: $ui-button-destructive-background-color;
|
||||
color: $ui-button-destructive-background-color;
|
||||
flex: 0 0 auto;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
background: transparent;
|
||||
border-color: $ui-button-background-color;
|
||||
color: $ui-button-background-color;
|
||||
background: $ui-button-destructive-background-color;
|
||||
border-color: $ui-button-destructive-background-color;
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -8003,20 +8047,9 @@ img.modal-warning {
|
|||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
|
||||
&.checked {
|
||||
&.checked,
|
||||
&.indeterminate {
|
||||
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 {
|
||||
|
@ -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 {
|
||||
&__input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 2px;
|
||||
|
||||
&.checked {
|
||||
&.checked,
|
||||
&.indeterminate {
|
||||
background: $ui-highlight-color;
|
||||
color: $white;
|
||||
|
||||
&::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -8207,6 +8249,11 @@ noscript {
|
|||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $no-gap-breakpoint) {
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.drawer__backdrop {
|
||||
|
@ -10761,12 +10808,28 @@ noscript {
|
|||
}
|
||||
|
||||
.notification-request {
|
||||
$padding: 15px;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 15px;
|
||||
padding: $padding;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
@ -10824,6 +10887,31 @@ noscript {
|
|||
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 {
|
||||
|
|
|
@ -83,11 +83,6 @@
|
|||
max-height: 35vh;
|
||||
padding: 0 6px 6px;
|
||||
will-change: transform;
|
||||
|
||||
&::-webkit-scrollbar-track:hover,
|
||||
&::-webkit-scrollbar-track:active {
|
||||
background-color: rgba($base-overlay-background, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-mart-search {
|
||||
|
@ -116,7 +111,6 @@
|
|||
&:focus {
|
||||
outline: none !important;
|
||||
border-width: 1px !important;
|
||||
border-color: $ui-button-background-color;
|
||||
}
|
||||
|
||||
&::-webkit-search-cancel-button {
|
||||
|
|
|
@ -214,12 +214,6 @@ html {
|
|||
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 {
|
||||
color: $white;
|
||||
}
|
||||
|
@ -620,3 +614,11 @@ a.sparkline {
|
|||
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-lighter-color: #b0c0cf;
|
||||
$ui-primary-color: #9bcbed;
|
||||
$ui-primary-color: $classic-primary-color !default;
|
||||
$ui-secondary-color: $classic-base-color !default;
|
||||
$ui-highlight-color: $classic-highlight-color !default;
|
||||
|
||||
|
|
|
@ -56,40 +56,3 @@ table {
|
|||
html {
|
||||
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_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_SUCCESS = 'NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS';
|
||||
export const NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL = 'NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL';
|
||||
|
@ -496,6 +504,62 @@ export const dismissNotificationRequestFail = (id, 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) => {
|
||||
const current = getState().getIn(['notificationRequests', 'current']);
|
||||
const params = { account_id: accountId };
|
||||
|
|
|
@ -2,8 +2,8 @@ import { apiRequestGet, apiRequestPut } from 'mastodon/api';
|
|||
import type { NotificationPolicyJSON } from 'mastodon/api_types/notification_policies';
|
||||
|
||||
export const apiGetNotificationPolicy = () =>
|
||||
apiRequestGet<NotificationPolicyJSON>('/v1/notifications/policy');
|
||||
apiRequestGet<NotificationPolicyJSON>('/v2/notifications/policy');
|
||||
|
||||
export const apiUpdateNotificationsPolicy = (
|
||||
policy: Partial<NotificationPolicyJSON>,
|
||||
) => apiRequestPut<NotificationPolicyJSON>('/v1/notifications/policy', policy);
|
||||
) => apiRequestPut<NotificationPolicyJSON>('/v2/notifications/policy', policy);
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
// See app/serializers/rest/notification_policy_serializer.rb
|
||||
|
||||
export type NotificationPolicyValue = 'accept' | 'filter' | 'drop';
|
||||
|
||||
export interface NotificationPolicyJSON {
|
||||
filter_not_following: boolean;
|
||||
filter_not_followers: boolean;
|
||||
filter_new_accounts: boolean;
|
||||
filter_private_mentions: boolean;
|
||||
for_not_following: NotificationPolicyValue;
|
||||
for_not_followers: NotificationPolicyValue;
|
||||
for_new_accounts: NotificationPolicyValue;
|
||||
for_private_mentions: NotificationPolicyValue;
|
||||
for_limited_accounts: NotificationPolicyValue;
|
||||
summary: {
|
||||
pending_requests_count: number;
|
||||
pending_notifications_count: number;
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
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 { Icon } from './icon';
|
||||
|
@ -7,6 +8,7 @@ import { Icon } from './icon';
|
|||
interface Props {
|
||||
value: string;
|
||||
checked: boolean;
|
||||
indeterminate: boolean;
|
||||
name: string;
|
||||
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
label: React.ReactNode;
|
||||
|
@ -16,6 +18,7 @@ export const CheckBox: React.FC<Props> = ({
|
|||
name,
|
||||
value,
|
||||
checked,
|
||||
indeterminate,
|
||||
onChange,
|
||||
label,
|
||||
}) => {
|
||||
|
@ -29,8 +32,14 @@ export const CheckBox: React.FC<Props> = ({
|
|||
onChange={onChange}
|
||||
/>
|
||||
|
||||
<span className={classNames('check-box__input', { checked })}>
|
||||
{checked && <Icon id='check' icon={DoneIcon} />}
|
||||
<span
|
||||
className={classNames('check-box__input', { checked, indeterminate })}
|
||||
>
|
||||
{indeterminate ? (
|
||||
<Icon id='indeterminate' icon={CheckIndeterminateSmallIcon} />
|
||||
) : (
|
||||
checked && <Icon id='check' icon={DoneIcon} />
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span>{label}</span>
|
||||
|
|
|
@ -13,7 +13,7 @@ const listenerOptions = supportsPassiveEvents
|
|||
? { passive: true, capture: true }
|
||||
: true;
|
||||
|
||||
interface SelectItem {
|
||||
export interface SelectItem {
|
||||
value: string;
|
||||
icon?: string;
|
||||
iconComponent?: IconProp;
|
||||
|
|
|
@ -3,15 +3,21 @@ import { useCallback } from 'react';
|
|||
|
||||
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 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 { initReport } from 'mastodon/actions/reports';
|
||||
import { Avatar } from 'mastodon/components/avatar';
|
||||
import { CheckBox } from 'mastodon/components/check_box';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
|
||||
import { makeGetAccount } from 'mastodon/selectors';
|
||||
import { toCappedNumber } from 'mastodon/utils/numbers';
|
||||
|
||||
|
@ -20,12 +26,18 @@ const getAccount = makeGetAccount();
|
|||
const messages = defineMessages({
|
||||
accept: { id: 'notification_requests.accept', defaultMessage: 'Accept' },
|
||||
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 account = useSelector(state => getAccount(state, accountId));
|
||||
const intl = useIntl();
|
||||
const { push: historyPush } = useHistory();
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
dispatch(dismissNotificationRequest(id));
|
||||
|
@ -35,9 +47,51 @@ export const NotificationRequest = ({ id, accountId, notificationsCount }) => {
|
|||
dispatch(acceptNotificationRequest(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 (
|
||||
<div className='notification-request'>
|
||||
<Link to={`/notifications/requests/${id}`} className='notification-request__link'>
|
||||
/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- this is just a minor affordance, but we will need a comprehensive accessibility pass */
|
||||
<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)} />
|
||||
|
||||
<div className='notification-request__name'>
|
||||
|
@ -51,7 +105,13 @@ export const NotificationRequest = ({ id, accountId, notificationsCount }) => {
|
|||
|
||||
<div className='notification-request__actions'>
|
||||
<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>
|
||||
);
|
||||
|
@ -61,4 +121,7 @@ NotificationRequest.propTypes = {
|
|||
id: PropTypes.string.isRequired,
|
||||
accountId: 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 { FormattedMessage } from 'react-intl';
|
||||
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { updateNotificationsPolicy } from 'mastodon/actions/notification_policies';
|
||||
import type { AppDispatch } from 'mastodon/store';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
import { CheckboxWithLabel } from './checkbox_with_label';
|
||||
import { SelectWithLabel } from './select_with_label';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
const noop = () => {};
|
||||
const messages = defineMessages({
|
||||
accept: { id: 'notifications.policy.accept', defaultMessage: 'Accept' },
|
||||
accept_hint: {
|
||||
id: 'notifications.policy.accept_hint',
|
||||
defaultMessage: 'Show in notifications',
|
||||
},
|
||||
filter: { id: 'notifications.policy.filter', defaultMessage: 'Filter' },
|
||||
filter_hint: {
|
||||
id: 'notifications.policy.filter_hint',
|
||||
defaultMessage: 'Send to filtered notifications inbox',
|
||||
},
|
||||
drop: { id: 'notifications.policy.drop', defaultMessage: 'Ignore' },
|
||||
drop_hint: {
|
||||
id: 'notifications.policy.drop_hint',
|
||||
defaultMessage: 'Send to the void, never to be seen again',
|
||||
},
|
||||
});
|
||||
|
||||
// TODO: change the following when we change the API
|
||||
const changeFilter = (
|
||||
dispatch: AppDispatch,
|
||||
filterType: string,
|
||||
value: string,
|
||||
) => {
|
||||
if (value === 'drop') {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'IGNORE_NOTIFICATIONS',
|
||||
modalProps: { filterType },
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
void dispatch(updateNotificationsPolicy({ [filterType]: value }));
|
||||
}
|
||||
};
|
||||
|
||||
export const PolicyControls: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const notificationPolicy = useAppSelector(
|
||||
|
@ -18,56 +54,74 @@ export const PolicyControls: React.FC = () => {
|
|||
);
|
||||
|
||||
const handleFilterNotFollowing = useCallback(
|
||||
(checked: boolean) => {
|
||||
void dispatch(
|
||||
updateNotificationsPolicy({ filter_not_following: checked }),
|
||||
);
|
||||
(value: string) => {
|
||||
changeFilter(dispatch, 'for_not_following', value);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleFilterNotFollowers = useCallback(
|
||||
(checked: boolean) => {
|
||||
void dispatch(
|
||||
updateNotificationsPolicy({ filter_not_followers: checked }),
|
||||
);
|
||||
(value: string) => {
|
||||
changeFilter(dispatch, 'for_not_followers', value);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleFilterNewAccounts = useCallback(
|
||||
(checked: boolean) => {
|
||||
void dispatch(
|
||||
updateNotificationsPolicy({ filter_new_accounts: checked }),
|
||||
);
|
||||
(value: string) => {
|
||||
changeFilter(dispatch, 'for_new_accounts', value);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleFilterPrivateMentions = useCallback(
|
||||
(checked: boolean) => {
|
||||
void dispatch(
|
||||
updateNotificationsPolicy({ filter_private_mentions: checked }),
|
||||
);
|
||||
(value: string) => {
|
||||
changeFilter(dispatch, 'for_private_mentions', value);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleFilterLimitedAccounts = useCallback(
|
||||
(value: string) => {
|
||||
changeFilter(dispatch, 'for_limited_accounts', value);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
if (!notificationPolicy) return null;
|
||||
|
||||
const options = [
|
||||
{
|
||||
value: 'accept',
|
||||
text: intl.formatMessage(messages.accept),
|
||||
meta: intl.formatMessage(messages.accept_hint),
|
||||
},
|
||||
{
|
||||
value: 'filter',
|
||||
text: intl.formatMessage(messages.filter),
|
||||
meta: intl.formatMessage(messages.filter_hint),
|
||||
},
|
||||
{
|
||||
value: 'drop',
|
||||
text: intl.formatMessage(messages.drop),
|
||||
meta: intl.formatMessage(messages.drop_hint),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h3>
|
||||
<FormattedMessage
|
||||
id='notifications.policy.title'
|
||||
defaultMessage='Filter out notifications from…'
|
||||
defaultMessage='Manage notifications from…'
|
||||
/>
|
||||
</h3>
|
||||
|
||||
<div className='column-settings__row'>
|
||||
<CheckboxWithLabel
|
||||
checked={notificationPolicy.filter_not_following}
|
||||
<SelectWithLabel
|
||||
value={notificationPolicy.for_not_following}
|
||||
onChange={handleFilterNotFollowing}
|
||||
options={options}
|
||||
>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
|
@ -81,11 +135,12 @@ export const PolicyControls: React.FC = () => {
|
|||
defaultMessage='Until you manually approve them'
|
||||
/>
|
||||
</span>
|
||||
</CheckboxWithLabel>
|
||||
</SelectWithLabel>
|
||||
|
||||
<CheckboxWithLabel
|
||||
checked={notificationPolicy.filter_not_followers}
|
||||
<SelectWithLabel
|
||||
value={notificationPolicy.for_not_followers}
|
||||
onChange={handleFilterNotFollowers}
|
||||
options={options}
|
||||
>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
|
@ -100,11 +155,12 @@ export const PolicyControls: React.FC = () => {
|
|||
values={{ days: 3 }}
|
||||
/>
|
||||
</span>
|
||||
</CheckboxWithLabel>
|
||||
</SelectWithLabel>
|
||||
|
||||
<CheckboxWithLabel
|
||||
checked={notificationPolicy.filter_new_accounts}
|
||||
<SelectWithLabel
|
||||
value={notificationPolicy.for_new_accounts}
|
||||
onChange={handleFilterNewAccounts}
|
||||
options={options}
|
||||
>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
|
@ -119,11 +175,12 @@ export const PolicyControls: React.FC = () => {
|
|||
values={{ days: 30 }}
|
||||
/>
|
||||
</span>
|
||||
</CheckboxWithLabel>
|
||||
</SelectWithLabel>
|
||||
|
||||
<CheckboxWithLabel
|
||||
checked={notificationPolicy.filter_private_mentions}
|
||||
<SelectWithLabel
|
||||
value={notificationPolicy.for_private_mentions}
|
||||
onChange={handleFilterPrivateMentions}
|
||||
options={options}
|
||||
>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
|
@ -137,9 +194,13 @@ export const PolicyControls: React.FC = () => {
|
|||
defaultMessage="Filtered unless it's in reply to your own mention or if you follow the sender"
|
||||
/>
|
||||
</span>
|
||||
</CheckboxWithLabel>
|
||||
</SelectWithLabel>
|
||||
|
||||
<CheckboxWithLabel checked disabled onChange={noop}>
|
||||
<SelectWithLabel
|
||||
value={notificationPolicy.for_limited_accounts}
|
||||
onChange={handleFilterLimitedAccounts}
|
||||
options={options}
|
||||
>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
id='notifications.policy.filter_limited_accounts_title'
|
||||
|
@ -152,7 +213,7 @@ export const PolicyControls: React.FC = () => {
|
|||
defaultMessage='Limited by server moderators'
|
||||
/>
|
||||
</span>
|
||||
</CheckboxWithLabel>
|
||||
</SelectWithLabel>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
|
|
@ -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 { useRef, useCallback, useEffect } from 'react';
|
||||
import { useRef, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
|
@ -8,11 +8,15 @@ import { Helmet } from 'react-helmet';
|
|||
import { useSelector, useDispatch } from 'react-redux';
|
||||
|
||||
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 { CheckBox } from 'mastodon/components/check_box';
|
||||
import Column from 'mastodon/components/column';
|
||||
import ColumnHeader from 'mastodon/components/column_header';
|
||||
import ScrollableList from 'mastodon/components/scrollable_list';
|
||||
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
|
||||
|
||||
import { NotificationRequest } from './components/notification_request';
|
||||
import { PolicyControls } from './components/policy_controls';
|
||||
|
@ -20,7 +24,18 @@ import SettingToggle from './components/setting_toggle';
|
|||
|
||||
const messages = defineMessages({
|
||||
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 = () => {
|
||||
|
@ -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 }) => {
|
||||
const columnRef = useRef();
|
||||
const intl = useIntl();
|
||||
|
@ -63,10 +196,40 @@ export const NotificationRequests = ({ multiColumn }) => {
|
|||
const notificationRequests = useSelector(state => state.getIn(['notificationRequests', 'items']));
|
||||
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(() => {
|
||||
columnRef.current?.scrollTop();
|
||||
}, [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(() => {
|
||||
dispatch(expandNotificationRequests());
|
||||
}, [dispatch]);
|
||||
|
@ -84,6 +247,8 @@ export const NotificationRequests = ({ multiColumn }) => {
|
|||
onClick={handleHeaderClick}
|
||||
multiColumn={multiColumn}
|
||||
showBackButton
|
||||
appendContent={
|
||||
<SelectRow selectionMode={selectionMode} setSelectionMode={setSelectionMode} selectAllChecked={selectAllChecked} toggleSelectAll={toggleSelectAll} selectedItems={checkedRequestIds} />}
|
||||
>
|
||||
<ColumnSettings />
|
||||
</ColumnHeader>
|
||||
|
@ -104,6 +269,9 @@ export const NotificationRequests = ({ multiColumn }) => {
|
|||
id={request.get('id')}
|
||||
accountId={request.get('account')}
|
||||
notificationsCount={request.get('notifications_count')}
|
||||
showCheckbox={selectionMode}
|
||||
checked={checkedRequestIds.includes(request.get('id'))}
|
||||
toggleCheck={handleCheck}
|
||||
/>
|
||||
))}
|
||||
</ScrollableList>
|
||||
|
|
|
@ -2,26 +2,33 @@ import { FormattedMessage } from 'react-intl';
|
|||
|
||||
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.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 { Status } from 'mastodon/models/status';
|
||||
import { useAppSelector } from 'mastodon/store';
|
||||
|
||||
import type { LabelRenderer } from './notification_group_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
|
||||
id='notification.mention'
|
||||
defaultMessage='{name} mentioned you'
|
||||
values={values}
|
||||
id='notification.label.private_mention'
|
||||
defaultMessage='Private mention'
|
||||
/>
|
||||
);
|
||||
|
||||
const privateMentionLabelRenderer: LabelRenderer = (values) => (
|
||||
const replyLabelRenderer: LabelRenderer = () => (
|
||||
<FormattedMessage id='notification.label.reply' defaultMessage='Reply' />
|
||||
);
|
||||
|
||||
const privateReplyLabelRenderer: LabelRenderer = () => (
|
||||
<FormattedMessage
|
||||
id='notification.private_mention'
|
||||
defaultMessage='{name} privately mentioned you'
|
||||
values={values}
|
||||
id='notification.label.private_reply'
|
||||
defaultMessage='Private reply'
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -29,27 +36,30 @@ export const NotificationMention: React.FC<{
|
|||
notification: NotificationGroupMention;
|
||||
unread: boolean;
|
||||
}> = ({ notification, unread }) => {
|
||||
const statusVisibility = useAppSelector(
|
||||
(state) =>
|
||||
state.statuses.getIn([
|
||||
notification.statusId,
|
||||
'visibility',
|
||||
]) as StatusVisibility,
|
||||
);
|
||||
const [isDirect, isReply] = useAppSelector((state) => {
|
||||
const status = state.statuses.get(notification.statusId) as Status;
|
||||
|
||||
return [
|
||||
status.get('visibility') === 'direct',
|
||||
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 (
|
||||
<NotificationWithStatus
|
||||
type='mention'
|
||||
icon={statusVisibility === 'direct' ? AlternateEmailIcon : ReplyIcon}
|
||||
icon={isReply ? ReplyIcon : AlternateEmailIcon}
|
||||
iconId='reply'
|
||||
accountIds={notification.sampleAccountIds}
|
||||
count={notification.notifications_count}
|
||||
statusId={notification.statusId}
|
||||
labelRenderer={
|
||||
statusVisibility === 'direct'
|
||||
? privateMentionLabelRenderer
|
||||
: labelRenderer
|
||||
}
|
||||
labelRenderer={labelRenderer}
|
||||
unread={unread}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -4,8 +4,6 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
|||
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
import DoneAllIcon from '@/material-icons/400-24px/done_all.svg?react';
|
||||
|
@ -27,16 +25,13 @@ import {
|
|||
selectUnreadNotificationGroupsCount,
|
||||
selectPendingNotificationGroupsCount,
|
||||
selectAnyPendingNotification,
|
||||
selectNotificationGroups,
|
||||
} from 'mastodon/selectors/notifications';
|
||||
import {
|
||||
selectNeedsNotificationPermission,
|
||||
selectSettingsNotificationsExcludedTypes,
|
||||
selectSettingsNotificationsQuickFilterActive,
|
||||
selectSettingsNotificationsQuickFilterShow,
|
||||
selectSettingsNotificationsShowUnread,
|
||||
} from 'mastodon/selectors/settings';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
import type { RootState } from 'mastodon/store';
|
||||
|
||||
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
||||
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<{
|
||||
columnId?: string;
|
||||
multiColumn?: boolean;
|
||||
}> = ({ columnId, multiColumn }) => {
|
||||
const intl = useIntl();
|
||||
const notifications = useAppSelector(getNotifications);
|
||||
const notifications = useAppSelector(selectNotificationGroups);
|
||||
const dispatch = useAppDispatch();
|
||||
const isLoading = useAppSelector((s) => s.notificationGroups.isLoading);
|
||||
const hasMore = notifications.at(-1)?.type === 'gap';
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
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 { Icon } from 'mastodon/components/icon';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
import { TimelineHint } from 'mastodon/components/timeline_hint';
|
||||
import ScrollContainer from 'mastodon/containers/scroll_container';
|
||||
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
|
||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||
|
@ -598,7 +599,7 @@ class Status extends ImmutablePureComponent {
|
|||
};
|
||||
|
||||
render () {
|
||||
let ancestors, descendants;
|
||||
let ancestors, descendants, remoteHint;
|
||||
const { isLoading, status, ancestorsIds, descendantsIds, intl, domain, multiColumn, pictureInPicture } = this.props;
|
||||
const { fullscreen } = this.state;
|
||||
|
||||
|
@ -627,6 +628,10 @@ class Status extends ImmutablePureComponent {
|
|||
const isLocal = status.getIn(['account', 'acct'], '').indexOf('@') === -1;
|
||||
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 = {
|
||||
moveUp: this.handleHotkeyMoveUp,
|
||||
moveDown: this.handleHotkeyMoveDown,
|
||||
|
@ -695,6 +700,7 @@ class Status extends ImmutablePureComponent {
|
|||
</HotKeys>
|
||||
|
||||
{descendants}
|
||||
{remoteHint}
|
||||
</div>
|
||||
</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,
|
||||
SubscribedLanguagesModal,
|
||||
ClosedRegistrationsModal,
|
||||
IgnoreNotificationsModal,
|
||||
} from 'mastodon/features/ui/util/async-components';
|
||||
import { getScrollbarWidth } from 'mastodon/utils/scrollbar';
|
||||
|
||||
|
@ -70,6 +71,7 @@ export const MODAL_COMPONENTS = {
|
|||
'SUBSCRIBED_LANGUAGES': SubscribedLanguagesModal,
|
||||
'INTERACTION': InteractionModal,
|
||||
'CLOSED_REGISTRATIONS': ClosedRegistrationsModal,
|
||||
'IGNORE_NOTIFICATIONS': IgnoreNotificationsModal,
|
||||
};
|
||||
|
||||
export default class ModalRoot extends PureComponent {
|
||||
|
|
|
@ -134,6 +134,10 @@ export function ReportModal () {
|
|||
return import(/* webpackChunkName: "modals/report_modal" */'../components/report_modal');
|
||||
}
|
||||
|
||||
export function IgnoreNotificationsModal () {
|
||||
return import(/* webpackChunkName: "modals/domain_block_modal" */'../components/ignore_notifications_modal');
|
||||
}
|
||||
|
||||
export function MediaGallery () {
|
||||
return import(/* webpackChunkName: "status/media_gallery" */'../../../components/media_gallery');
|
||||
}
|
||||
|
|
|
@ -356,6 +356,17 @@
|
|||
"home.pending_critical_update.link": "See updates",
|
||||
"home.pending_critical_update.title": "Critical security update available!",
|
||||
"home.show_announcements": "Show announcements",
|
||||
"ignore_notifications_modal.disclaimer": "Mastodon cannot inform users that you've ignored their notifications. Ignoring notifications will not stop the messages themselves from being sent.",
|
||||
"ignore_notifications_modal.filter_instead": "Filter instead",
|
||||
"ignore_notifications_modal.filter_to_act_users": "Filtering helps avoid potential confusion",
|
||||
"ignore_notifications_modal.filter_to_avoid_confusion": "Filtering helps avoid potential confusion",
|
||||
"ignore_notifications_modal.filter_to_review_separately": "You can review filtered notifications speparately",
|
||||
"ignore_notifications_modal.ignore": "Ignore notifications",
|
||||
"ignore_notifications_modal.limited_accounts_title": "Ignore notifications from moderated accounts?",
|
||||
"ignore_notifications_modal.new_accounts_title": "Ignore notifications from new accounts?",
|
||||
"ignore_notifications_modal.not_followers_title": "Ignore notifications from people not following you?",
|
||||
"ignore_notifications_modal.not_following_title": "Ignore notifications from people you don't follow?",
|
||||
"ignore_notifications_modal.private_mentions_title": "Ignore notifications from unsolicited Private Mentions?",
|
||||
"interaction_modal.description.favourite": "With an account on Mastodon, you can favorite this post to let the author know you appreciate it and save it for later.",
|
||||
"interaction_modal.description.follow": "With an account on Mastodon, you can follow {name} to receive their posts in your home feed.",
|
||||
"interaction_modal.description.reblog": "With an account on Mastodon, you can boost this post to share it with your own followers.",
|
||||
|
@ -482,7 +493,11 @@
|
|||
"notification.favourite": "{name} favorited your post",
|
||||
"notification.follow": "{name} followed 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": "You have received a moderation warning",
|
||||
"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.own_poll": "Your poll 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.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.",
|
||||
|
@ -504,13 +518,26 @@
|
|||
"notification.status": "{name} just posted",
|
||||
"notification.update": "{name} edited a post",
|
||||
"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_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_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.minimize_banner": "Minimize filtered notifications banner",
|
||||
"notification_requests.notifications_from": "Notifications from {name}",
|
||||
"notification_requests.title": "Filtered notifications",
|
||||
"notification_requests.view": "View notifications",
|
||||
"notifications.clear": "Clear notifications",
|
||||
"notifications.clear_confirmation": "Are you sure you want to permanently clear all your 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_alert": "Desktop notifications can't be enabled, as browser permission has been denied before",
|
||||
"notifications.permission_required": "Desktop notifications are unavailable because the required permission has not been granted.",
|
||||
"notifications.policy.accept": "Accept",
|
||||
"notifications.policy.accept_hint": "Show in notifications",
|
||||
"notifications.policy.drop": "Ignore",
|
||||
"notifications.policy.drop_hint": "Send to the void, never to be seen again",
|
||||
"notifications.policy.filter": "Filter",
|
||||
"notifications.policy.filter_hint": "Send to filtered notifications inbox",
|
||||
"notifications.policy.filter_limited_accounts_hint": "Limited by server moderators",
|
||||
"notifications.policy.filter_limited_accounts_title": "Moderated accounts",
|
||||
"notifications.policy.filter_new_accounts.hint": "Created within the past {days, plural, one {one day} other {# days}}",
|
||||
|
@ -557,7 +590,7 @@
|
|||
"notifications.policy.filter_not_following_title": "People you don't follow",
|
||||
"notifications.policy.filter_private_mentions_hint": "Filtered unless it's in reply to your own mention or if you follow the sender",
|
||||
"notifications.policy.filter_private_mentions_title": "Unsolicited private mentions",
|
||||
"notifications.policy.title": "Filter out notifications from…",
|
||||
"notifications.policy.title": "Manage notifications from…",
|
||||
"notifications_permission_banner.enable": "Enable desktop notifications",
|
||||
"notifications_permission_banner.how_to_control": "To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled.",
|
||||
"notifications_permission_banner.title": "Never miss a thing",
|
||||
|
@ -798,6 +831,7 @@
|
|||
"timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
|
||||
"timeline_hint.resources.followers": "Followers",
|
||||
"timeline_hint.resources.follows": "Follows",
|
||||
"timeline_hint.resources.replies": "Some replies",
|
||||
"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.trending_now": "Trending now",
|
||||
|
|
|
@ -13,6 +13,8 @@ import {
|
|||
NOTIFICATION_REQUEST_FETCH_FAIL,
|
||||
NOTIFICATION_REQUEST_ACCEPT_REQUEST,
|
||||
NOTIFICATION_REQUEST_DISMISS_REQUEST,
|
||||
NOTIFICATION_REQUESTS_ACCEPT_REQUEST,
|
||||
NOTIFICATION_REQUESTS_DISMISS_REQUEST,
|
||||
NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST,
|
||||
NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS,
|
||||
NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL,
|
||||
|
@ -83,6 +85,9 @@ export const notificationRequestsReducer = (state = initialState, action) => {
|
|||
case NOTIFICATION_REQUEST_ACCEPT_REQUEST:
|
||||
case NOTIFICATION_REQUEST_DISMISS_REQUEST:
|
||||
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:
|
||||
return removeRequestByAccount(state, action.payload.relationship.id);
|
||||
case muteAccountSuccess.type:
|
||||
|
|
|
@ -1,15 +1,62 @@
|
|||
import { createSelector } from '@reduxjs/toolkit';
|
||||
|
||||
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 {
|
||||
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(
|
||||
[
|
||||
(s: RootState) => s.notificationGroups.lastReadId,
|
||||
(s: RootState) => s.notificationGroups.pendingGroups,
|
||||
(s: RootState) => s.notificationGroups.groups,
|
||||
selectNotificationGroups,
|
||||
selectPendingNotificationGroups,
|
||||
],
|
||||
(notificationMarker, pendingGroups, groups) => {
|
||||
(notificationMarker, groups, pendingGroups) => {
|
||||
return (
|
||||
groups.filter(
|
||||
(group) =>
|
||||
|
@ -31,7 +78,7 @@ export const selectUnreadNotificationGroupsCount = createSelector(
|
|||
export const selectAnyPendingNotification = createSelector(
|
||||
[
|
||||
(s: RootState) => s.notificationGroups.readMarkerId,
|
||||
(s: RootState) => s.notificationGroups.groups,
|
||||
selectNotificationGroups,
|
||||
],
|
||||
(notificationMarker, groups) => {
|
||||
return groups.some(
|
||||
|
@ -44,7 +91,7 @@ export const selectAnyPendingNotification = createSelector(
|
|||
);
|
||||
|
||||
export const selectPendingNotificationGroupsCount = createSelector(
|
||||
[(s: RootState) => s.notificationGroups.pendingGroups],
|
||||
[selectPendingNotificationGroups],
|
||||
(pendingGroups) =>
|
||||
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%);
|
||||
}
|
||||
|
||||
.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 {
|
||||
color: $white;
|
||||
}
|
||||
|
@ -557,3 +551,11 @@ a.sparkline {
|
|||
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-lighter-color: #b0c0cf;
|
||||
$ui-primary-color: #9bcbed;
|
||||
$ui-primary-color: $classic-primary-color !default;
|
||||
$ui-secondary-color: $classic-base-color !default;
|
||||
$ui-highlight-color: $classic-highlight-color !default;
|
||||
|
||||
|
|
|
@ -877,6 +877,13 @@ body > [data-popper-placement] {
|
|||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&[disabled] {
|
||||
cursor: default;
|
||||
color: $highlight-text-color;
|
||||
border-color: $highlight-text-color;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
|
@ -2779,6 +2786,11 @@ $ui-header-logo-wordmark-width: 99px;
|
|||
&.privacy-policy {
|
||||
border-top: 1px solid var(--background-border-color);
|
||||
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;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
color: $inverted-text-color;
|
||||
background: $white;
|
||||
color: $primary-text-color;
|
||||
background: $ui-base-color;
|
||||
padding: 7px 10px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid $white;
|
||||
border: 1px solid var(--background-border-color);
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
border-color: lighten($ui-highlight-color, 12%);
|
||||
}
|
||||
|
||||
&__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 {
|
||||
display: flex;
|
||||
font-size: 16px;
|
||||
|
@ -4472,6 +4513,11 @@ a.status-card {
|
|||
.column-header__collapsible-inner {
|
||||
border: 1px solid var(--background-border-color);
|
||||
border-top: 0;
|
||||
|
||||
@media screen and (max-width: $no-gap-breakpoint) {
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.column-header__setting-btn {
|
||||
|
@ -6235,9 +6281,10 @@ a.status-card {
|
|||
max-width: 90vw;
|
||||
width: 480px;
|
||||
height: 80vh;
|
||||
background: lighten($ui-secondary-color, 8%);
|
||||
color: $inverted-text-color;
|
||||
border-radius: 8px;
|
||||
background: var(--background-color);
|
||||
color: $primary-text-color;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--background-border-color);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
|
@ -6245,7 +6292,7 @@ a.status-card {
|
|||
|
||||
&__container {
|
||||
box-sizing: border-box;
|
||||
border-top: 1px solid $ui-secondary-color;
|
||||
border-top: 1px solid var(--background-border-color);
|
||||
padding: 20px;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
|
@ -6275,7 +6322,7 @@ a.status-card {
|
|||
&__lead {
|
||||
font-size: 17px;
|
||||
line-height: 22px;
|
||||
color: lighten($inverted-text-color, 16%);
|
||||
color: $secondary-text-color;
|
||||
margin-bottom: 30px;
|
||||
|
||||
a {
|
||||
|
@ -6310,7 +6357,7 @@ a.status-card {
|
|||
|
||||
.status__content,
|
||||
.status__content p {
|
||||
color: $inverted-text-color;
|
||||
color: $primary-text-color;
|
||||
}
|
||||
|
||||
.status__content__spoiler-link {
|
||||
|
@ -6355,7 +6402,7 @@ a.status-card {
|
|||
.poll__option.dialog-option {
|
||||
padding: 15px 0;
|
||||
flex: 0 0 auto;
|
||||
border-bottom: 1px solid $ui-secondary-color;
|
||||
border-bottom: 1px solid var(--background-border-color);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
|
@ -6363,13 +6410,13 @@ a.status-card {
|
|||
|
||||
& > .poll__option__text {
|
||||
font-size: 13px;
|
||||
color: lighten($inverted-text-color, 16%);
|
||||
color: $secondary-text-color;
|
||||
|
||||
strong {
|
||||
font-size: 17px;
|
||||
font-weight: 500;
|
||||
line-height: 22px;
|
||||
color: $inverted-text-color;
|
||||
color: $primary-text-color;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
|
||||
|
@ -6388,22 +6435,19 @@ a.status-card {
|
|||
display: block;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
color: $inverted-text-color;
|
||||
background: $simple-background-color;
|
||||
color: $primary-text-color;
|
||||
background: $ui-base-color;
|
||||
padding: 10px;
|
||||
font-family: inherit;
|
||||
font-size: 17px;
|
||||
line-height: 22px;
|
||||
resize: vertical;
|
||||
border: 0;
|
||||
border: 1px solid var(--background-border-color);
|
||||
outline: 0;
|
||||
border-radius: 4px;
|
||||
margin: 20px 0;
|
||||
|
||||
&::placeholder {
|
||||
color: $dark-text-color;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
}
|
||||
|
@ -6424,16 +6468,16 @@ a.status-card {
|
|||
}
|
||||
|
||||
.button.button-secondary {
|
||||
border-color: $inverted-text-color;
|
||||
color: $inverted-text-color;
|
||||
border-color: $ui-button-destructive-background-color;
|
||||
color: $ui-button-destructive-background-color;
|
||||
flex: 0 0 auto;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
background: transparent;
|
||||
border-color: $ui-button-background-color;
|
||||
color: $ui-button-background-color;
|
||||
background: $ui-button-destructive-background-color;
|
||||
border-color: $ui-button-destructive-background-color;
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -7453,20 +7497,9 @@ a.status-card {
|
|||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
|
||||
&.checked {
|
||||
&.checked,
|
||||
&.indeterminate {
|
||||
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 {
|
||||
|
@ -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 {
|
||||
&__input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 2px;
|
||||
|
||||
&.checked {
|
||||
&.checked,
|
||||
&.indeterminate {
|
||||
background: $ui-highlight-color;
|
||||
color: $white;
|
||||
|
||||
&::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -7657,6 +7699,11 @@ noscript {
|
|||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $no-gap-breakpoint) {
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.drawer__backdrop {
|
||||
|
@ -10204,12 +10251,28 @@ noscript {
|
|||
}
|
||||
|
||||
.notification-request {
|
||||
$padding: 15px;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 15px;
|
||||
padding: $padding;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
@ -10267,6 +10330,31 @@ noscript {
|
|||
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 {
|
||||
|
|
|
@ -83,11 +83,6 @@
|
|||
max-height: 35vh;
|
||||
padding: 0 6px 6px;
|
||||
will-change: transform;
|
||||
|
||||
&::-webkit-scrollbar-track:hover,
|
||||
&::-webkit-scrollbar-track:active {
|
||||
background-color: rgba($base-overlay-background, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-mart-search {
|
||||
|
@ -116,7 +111,6 @@
|
|||
&:focus {
|
||||
outline: none !important;
|
||||
border-width: 1px !important;
|
||||
border-color: $ui-button-background-color;
|
||||
}
|
||||
|
||||
&::-webkit-search-cancel-button {
|
||||
|
|
|
@ -56,40 +56,3 @@ table {
|
|||
html {
|
||||
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
|
||||
|
||||
def json
|
||||
@json ||= root_array(Oj.load(@data))
|
||||
.map { |node| JSON::LD::API.compact(node, 'https://schema.org') }
|
||||
.find { |node| SUPPORTED_TYPES.include?(node['type']) } || {}
|
||||
@json ||= root_array(Oj.load(@data)).compact.find { |obj| SUPPORTED_TYPES.include?(obj['@type']) } || {}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -276,6 +276,9 @@ class MediaAttachment < ApplicationRecord
|
|||
before_create :set_unknown_type
|
||||
before_create :set_processing
|
||||
|
||||
before_destroy :prepare_cache_bust!, prepend: true
|
||||
after_destroy :bust_cache!
|
||||
|
||||
after_commit :enqueue_processing, on: :create
|
||||
after_commit :reset_parent_cache, on: :update
|
||||
|
||||
|
@ -410,4 +413,29 @@ class MediaAttachment < ApplicationRecord
|
|||
def reset_parent_cache
|
||||
Rails.cache.delete("v3:statuses/#{status_id}") if status_id.present?
|
||||
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
|
||||
|
|
|
@ -4,17 +4,25 @@
|
|||
#
|
||||
# Table name: notification_policies
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# account_id :bigint(8) not null
|
||||
# filter_not_following :boolean default(FALSE), not null
|
||||
# filter_not_followers :boolean default(FALSE), not null
|
||||
# filter_new_accounts :boolean default(FALSE), not null
|
||||
# filter_private_mentions :boolean default(TRUE), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# id :bigint(8) not null, primary key
|
||||
# account_id :bigint(8) not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# for_not_following :integer default("accept"), not null
|
||||
# for_not_followers :integer default("accept"), not null
|
||||
# for_new_accounts :integer default("accept"), not null
|
||||
# for_private_mentions :integer default("filter"), not null
|
||||
# for_limited_accounts :integer default("filter"), not null
|
||||
#
|
||||
|
||||
class NotificationPolicy < ApplicationRecord
|
||||
self.ignored_columns += %w(
|
||||
filter_not_following
|
||||
filter_not_followers
|
||||
filter_new_accounts
|
||||
filter_private_mentions
|
||||
)
|
||||
|
||||
belongs_to :account
|
||||
|
||||
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
|
||||
|
||||
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!
|
||||
@pending_requests_count = pending_notification_requests.first
|
||||
@pending_notifications_count = pending_notification_requests.last
|
||||
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
|
||||
|
||||
def pending_notification_requests
|
||||
|
|
|
@ -3,10 +3,11 @@
|
|||
class REST::NotificationPolicySerializer < ActiveModel::Serializer
|
||||
# Please update `app/javascript/mastodon/api_types/notification_policies.ts` when making changes to the attributes
|
||||
|
||||
attributes :filter_not_following,
|
||||
:filter_not_followers,
|
||||
:filter_new_accounts,
|
||||
:filter_private_mentions,
|
||||
attributes :for_not_following,
|
||||
:for_not_followers,
|
||||
:for_new_accounts,
|
||||
:for_private_mentions,
|
||||
:for_limited_accounts,
|
||||
: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
|
||||
).freeze
|
||||
|
||||
class DismissCondition
|
||||
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
|
||||
class BaseCondition
|
||||
NEW_ACCOUNT_THRESHOLD = 30.days.freeze
|
||||
|
||||
NEW_FOLLOWER_THRESHOLD = 3.days.freeze
|
||||
|
@ -82,39 +30,16 @@ class NotifyService < BaseService
|
|||
).freeze
|
||||
|
||||
def initialize(notification)
|
||||
@notification = notification
|
||||
@recipient = notification.account
|
||||
@sender = notification.from_account
|
||||
@notification = notification
|
||||
@policy = NotificationPolicy.find_or_initialize_by(account: @recipient)
|
||||
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
|
||||
|
||||
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?
|
||||
end
|
||||
|
||||
def filtered_by_private_mentions_policy?
|
||||
@policy.filter_private_mentions? && not_following? && private_mention_not_in_response?
|
||||
def filterable_type?
|
||||
Notification::PROPERTIES[@notification.type][:filterable]
|
||||
end
|
||||
|
||||
def not_following?
|
||||
|
@ -174,6 +99,112 @@ class NotifyService < BaseService
|
|||
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)
|
||||
return if recipient.user.nil?
|
||||
|
||||
|
@ -182,7 +213,7 @@ class NotifyService < BaseService
|
|||
@notification = Notification.new(account: @recipient, type: type, activity: @activity)
|
||||
|
||||
# For certain conditions we don't need to create a notification at all
|
||||
return if dismiss?
|
||||
return if drop?
|
||||
|
||||
@notification.filtered = filter?
|
||||
@notification.group_key = notification_group_key
|
||||
|
@ -222,8 +253,8 @@ class NotifyService < BaseService
|
|||
"#{type_prefix}-#{hour_bucket}"
|
||||
end
|
||||
|
||||
def dismiss?
|
||||
DismissCondition.new(@notification).dismiss?
|
||||
def drop?
|
||||
DropCondition.new(@notification).drop?
|
||||
end
|
||||
|
||||
def filter?
|
||||
|
|
|
@ -4,12 +4,12 @@
|
|||
|
||||
.batch-table__row__content.pending-account
|
||||
.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/
|
||||
|
||||
- if preview_card.provider_name.present?
|
||||
= preview_card.provider_name
|
||||
%span{ lang: preview_card.language }= preview_card.provider_name
|
||||
·
|
||||
|
||||
- if preview_card.language.present?
|
||||
|
|
|
@ -39,22 +39,22 @@
|
|||
.batch-table__toolbar__actions
|
||||
= f.button safe_join([material_symbol('check'), t('admin.trends.links.allow')]),
|
||||
class: 'table-action-link',
|
||||
data: { confirm: t('admin.reports.are_you_sure') },
|
||||
data: { confirm: t('admin.trends.links.confirm_allow') },
|
||||
name: :approve,
|
||||
type: :submit
|
||||
= f.button safe_join([material_symbol('check'), t('admin.trends.links.allow_provider')]),
|
||||
class: 'table-action-link',
|
||||
data: { confirm: t('admin.reports.are_you_sure') },
|
||||
data: { confirm: t('admin.trends.links.confirm_allow_provider') },
|
||||
name: :approve_providers,
|
||||
type: :submit
|
||||
= f.button safe_join([material_symbol('close'), t('admin.trends.links.disallow')]),
|
||||
class: 'table-action-link',
|
||||
data: { confirm: t('admin.reports.are_you_sure') },
|
||||
data: { confirm: t('admin.trends.links.confirm_disallow') },
|
||||
name: :reject,
|
||||
type: :submit
|
||||
= f.button safe_join([material_symbol('close'), t('admin.trends.links.disallow_provider')]),
|
||||
class: 'table-action-link',
|
||||
data: { confirm: t('admin.reports.are_you_sure') },
|
||||
data: { confirm: t('admin.trends.links.confirm_disallow_provider') },
|
||||
name: :reject_providers,
|
||||
type: :submit
|
||||
.batch-table__body
|
||||
|
|
|
@ -35,22 +35,22 @@
|
|||
.batch-table__toolbar__actions
|
||||
= f.button safe_join([material_symbol('check'), t('admin.trends.statuses.allow')]),
|
||||
class: 'table-action-link',
|
||||
data: { confirm: t('admin.reports.are_you_sure') },
|
||||
data: { confirm: t('admin.trends.statuses.confirm_allow') },
|
||||
name: :approve,
|
||||
type: :submit
|
||||
= f.button safe_join([material_symbol('check'), t('admin.trends.statuses.allow_account')]),
|
||||
class: 'table-action-link',
|
||||
data: { confirm: t('admin.reports.are_you_sure') },
|
||||
data: { confirm: t('admin.trends.statuses.confirm_allow_account') },
|
||||
name: :approve_accounts,
|
||||
type: :submit
|
||||
= f.button safe_join([material_symbol('close'), t('admin.trends.statuses.disallow')]),
|
||||
class: 'table-action-link',
|
||||
data: { confirm: t('admin.reports.are_you_sure') },
|
||||
data: { confirm: t('admin.trends.statuses.confirm_disallow') },
|
||||
name: :reject,
|
||||
type: :submit
|
||||
= f.button safe_join([material_symbol('close'), t('admin.trends.statuses.disallow_account')]),
|
||||
class: 'table-action-link',
|
||||
data: { confirm: t('admin.reports.are_you_sure') },
|
||||
data: { confirm: t('admin.trends.statuses.confirm_disallow_account') },
|
||||
name: :reject_accounts,
|
||||
type: :submit
|
||||
.batch-table__body
|
||||
|
|
|
@ -27,12 +27,12 @@
|
|||
.batch-table__toolbar__actions
|
||||
= f.button safe_join([material_symbol('check'), t('admin.trends.allow')]),
|
||||
class: 'table-action-link',
|
||||
data: { confirm: t('admin.reports.are_you_sure') },
|
||||
data: { confirm: t('admin.trends.confirm_allow') },
|
||||
name: :approve,
|
||||
type: :submit
|
||||
= f.button safe_join([material_symbol('close'), t('admin.trends.disallow')]),
|
||||
class: 'table-action-link',
|
||||
data: { confirm: t('admin.reports.are_you_sure') },
|
||||
data: { confirm: t('admin.trends.confirm_disallow') },
|
||||
name: :reject,
|
||||
type: :submit
|
||||
|
||||
|
|
|
@ -142,7 +142,7 @@ class Rack::Attack
|
|||
end
|
||||
|
||||
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
|
||||
|
||||
self.throttled_responder = lambda do |request|
|
||||
|
|
|
@ -907,10 +907,16 @@ en:
|
|||
trends:
|
||||
allow: Allow
|
||||
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
|
||||
links:
|
||||
allow: Allow link
|
||||
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.
|
||||
disallow: Disallow link
|
||||
disallow_provider: Disallow publisher
|
||||
|
@ -934,6 +940,10 @@ en:
|
|||
statuses:
|
||||
allow: Allow post
|
||||
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.
|
||||
disallow: Disallow post
|
||||
disallow_account: Disallow author
|
||||
|
|
|
@ -338,6 +338,10 @@ namespace :api, format: false do
|
|||
namespace :admin do
|
||||
resources :accounts, only: [:index]
|
||||
end
|
||||
|
||||
namespace :notifications do
|
||||
resource :policy, only: [:show, :update]
|
||||
end
|
||||
end
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
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|
|
||||
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 "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
|
||||
end
|
||||
|
||||
|
|
|
@ -107,8 +107,8 @@ namespace :tests do
|
|||
end
|
||||
|
||||
policy = NotificationPolicy.find_by(account: User.find(1).account)
|
||||
unless policy.filter_private_mentions == false && policy.filter_not_following == true
|
||||
puts 'Notification policy not migrated as expected'
|
||||
unless policy.for_private_mentions == 'accept' && policy.for_not_following == 'filter'
|
||||
puts "Notification policy not migrated as expected: #{policy.for_private_mentions.inspect}, #{policy.for_not_following.inspect}"
|
||||
exit(1)
|
||||
end
|
||||
|
||||
|
|
|
@ -180,9 +180,9 @@
|
|||
"eslint-import-resolver-typescript": "^3.5.5",
|
||||
"eslint-plugin-formatjs": "^4.10.1",
|
||||
"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-promise": "~6.6.0",
|
||||
"eslint-plugin-promise": "~7.1.0",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"husky": "^9.0.11",
|
||||
|
@ -192,6 +192,7 @@
|
|||
"prettier": "^3.3.3",
|
||||
"react-test-renderer": "^18.2.0",
|
||||
"stylelint": "^16.0.2",
|
||||
"stylelint-config-prettier-scss": "^1.0.0",
|
||||
"stylelint-config-standard-scss": "^13.0.0",
|
||||
"typescript": "^5.0.4",
|
||||
"webpack-dev-server": "^3.11.3"
|
||||
|
|
|
@ -79,16 +79,6 @@ RSpec.describe LinkDetailsExtractor do
|
|||
},
|
||||
}.to_json
|
||||
end
|
||||
let(:html) { <<~HTML }
|
||||
<!doctype html>
|
||||
<html>
|
||||
<body>
|
||||
<script type="application/ld+json">
|
||||
#{ld_json}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
HTML
|
||||
|
||||
shared_examples 'structured data' do
|
||||
it 'extracts the expected values from structured data' do
|
||||
|
@ -234,27 +224,21 @@ RSpec.describe LinkDetailsExtractor do
|
|||
},
|
||||
}.to_json
|
||||
end
|
||||
let(:html) { <<~HTML }
|
||||
<!doctype html>
|
||||
<html>
|
||||
<body>
|
||||
<script type="application/ld+json">
|
||||
#{ld_json}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
HTML
|
||||
|
||||
it 'joins author names' do
|
||||
expect(subject.author_name).to eq 'Author 1, Author 2'
|
||||
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
|
||||
|
||||
context 'when Open Graph protocol data is present' do
|
||||
|
|
|
@ -292,6 +292,25 @@ RSpec.describe MediaAttachment, :attachment_processing do
|
|||
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
|
||||
|
||||
def media_metadata
|
||||
|
|
|
@ -51,7 +51,7 @@ RSpec.describe 'Policies' do
|
|||
|
||||
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).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(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
|
||||
|
||||
describe NotifyService::DismissCondition do
|
||||
describe NotifyService::DropCondition do
|
||||
subject { described_class.new(notification) }
|
||||
|
||||
let(:activity) { Fabricate(:mention, status: Fabricate(:status)) }
|
||||
let(:notification) { Fabricate(:notification, type: :mention, activity: activity, from_account: activity.status.account, account: activity.account) }
|
||||
|
||||
describe '#dismiss?' do
|
||||
context 'when sender is silenced' do
|
||||
describe '#drop' do
|
||||
context 'when sender is silenced and recipient has a default policy' do
|
||||
before do
|
||||
notification.from_account.silence!
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
|
@ -219,7 +257,7 @@ RSpec.describe NotifyService do
|
|||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(subject.dismiss?).to be true
|
||||
expect(subject.drop?).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -250,6 +288,16 @@ RSpec.describe NotifyService do
|
|||
expect(subject.filter?).to be false
|
||||
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
|
||||
|
||||
context 'when recipient is filtering not-followed senders' do
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
module.exports = {
|
||||
extends: ['stylelint-config-standard-scss'],
|
||||
extends: ['stylelint-config-standard-scss', 'stylelint-config-prettier-scss'],
|
||||
ignoreFiles: [
|
||||
'app/javascript/styles/mastodon/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-plugin-formatjs: "npm:^4.10.1"
|
||||
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-promise: "npm:~6.6.0"
|
||||
eslint-plugin-promise: "npm:~7.1.0"
|
||||
eslint-plugin-react: "npm:^7.33.2"
|
||||
eslint-plugin-react-hooks: "npm:^4.6.0"
|
||||
exif-js: "npm:^2.3.0"
|
||||
|
@ -2948,6 +2948,7 @@ __metadata:
|
|||
stacktrace-js: "npm:^2.0.2"
|
||||
stringz: "npm:^2.1.0"
|
||||
stylelint: "npm:^16.0.2"
|
||||
stylelint-config-prettier-scss: "npm:^1.0.0"
|
||||
stylelint-config-standard-scss: "npm:^13.0.0"
|
||||
substring-trie: "npm:^1.0.2"
|
||||
terser-webpack-plugin: "npm:^4.2.3"
|
||||
|
@ -4620,12 +4621,12 @@ __metadata:
|
|||
languageName: node
|
||||
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":
|
||||
version: 8.11.2
|
||||
resolution: "acorn@npm:8.11.2"
|
||||
"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.12.1
|
||||
resolution: "acorn@npm:8.12.1"
|
||||
bin:
|
||||
acorn: bin/acorn
|
||||
checksum: 10c0/a3ed76c761b75ec54b1ec3068fb7f113a182e95aea7f322f65098c2958d232e3d211cb6dac35ff9c647024b63714bc528a26d54a925d1fef2c25585b4c8e4017
|
||||
checksum: 10c0/51fb26cd678f914e13287e886da2d7021f8c2bc0ccc95e03d3e0447ee278dd3b40b9c57dc222acd5881adcf26f3edc40901a4953403232129e3876793cd17386
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
@ -7977,15 +7978,16 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"eslint-plugin-jsdoc@npm:^48.0.0":
|
||||
version: 48.8.3
|
||||
resolution: "eslint-plugin-jsdoc@npm:48.8.3"
|
||||
"eslint-plugin-jsdoc@npm:^50.0.0":
|
||||
version: 50.0.0
|
||||
resolution: "eslint-plugin-jsdoc@npm:50.0.0"
|
||||
dependencies:
|
||||
"@es-joy/jsdoccomment": "npm:~0.46.0"
|
||||
are-docs-informative: "npm:^0.0.2"
|
||||
comment-parser: "npm:1.4.1"
|
||||
debug: "npm:^4.3.5"
|
||||
escape-string-regexp: "npm:^4.0.0"
|
||||
espree: "npm:^10.1.0"
|
||||
esquery: "npm:^1.6.0"
|
||||
parse-imports: "npm:^2.1.1"
|
||||
semver: "npm:^7.6.3"
|
||||
|
@ -7993,7 +7995,7 @@ __metadata:
|
|||
synckit: "npm:^0.9.1"
|
||||
peerDependencies:
|
||||
eslint: ^7.0.0 || ^8.0.0 || ^9.0.0
|
||||
checksum: 10c0/78d893614b188617de5a03d8163406455e3b739fd7b86192eb05a29cf8e7f06909a6f6a1b9dc2acd31e5ae2bccd94600eaea247d277f58c3c946c0fdb36a57f7
|
||||
checksum: 10c0/1d476eabdf604f4a07ef9a22fb7b13ba898d0aed81b2c428d4b6aea766b908ebdc7e6e82a16bac3f83e1013c6edba6d9a15a4015cab9a94c584ebccbd7255b70
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
@ -8023,12 +8025,12 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"eslint-plugin-promise@npm:~6.6.0":
|
||||
version: 6.6.0
|
||||
resolution: "eslint-plugin-promise@npm:6.6.0"
|
||||
"eslint-plugin-promise@npm:~7.1.0":
|
||||
version: 7.1.0
|
||||
resolution: "eslint-plugin-promise@npm:7.1.0"
|
||||
peerDependencies:
|
||||
eslint: ^7.0.0 || ^8.0.0 || ^9.0.0
|
||||
checksum: 10c0/93a667dbc9ff15c4d586b0d40a31c7828314cbbb31b2b9a75802aa4ef536e9457bb3e1a89b384b07aa336dd61b315ae8b0aadc0870210378023dd018819b59b3
|
||||
checksum: 10c0/bbc3406139715dfa5f48d04f6d5b5e82f68929d954b0fa3821eb8cd6dc381b210512cedd2d874e5de5381005d316566f4ae046a4750ce3f5f5cbf28a14cc0ab2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
@ -8096,6 +8098,13 @@ __metadata:
|
|||
languageName: node
|
||||
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":
|
||||
version: 8.57.0
|
||||
resolution: "eslint@npm:8.57.0"
|
||||
|
@ -8144,6 +8153,17 @@ __metadata:
|
|||
languageName: node
|
||||
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":
|
||||
version: 9.6.1
|
||||
resolution: "espree@npm:9.6.1"
|
||||
|
@ -16619,6 +16639,18 @@ __metadata:
|
|||
languageName: node
|
||||
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":
|
||||
version: 14.0.0
|
||||
resolution: "stylelint-config-recommended-scss@npm:14.0.0"
|
||||
|
|
Loading…
Reference in New Issue