[Glitch] Add ability to report, block and mute from notification requests list

Port 658addcbf7 to glitch-soc

Co-authored-by: Renaud Chaput <renchap@gmail.com>
Signed-off-by: Claire <claire.github-309c@sitedethib.com>
pull/2809/head
Claire 2024-08-09 16:56:39 +02:00
parent 58b9b80be5
commit b6961d08ab
6 changed files with 380 additions and 32 deletions

View File

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

View File

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

View File

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

View File

@ -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,94 @@ const ColumnSettings = () => {
);
};
const SelectRow = ({selectAllChecked, toggleSelectAll, selectedItems, selectionMode, setSelectionMode}) => {
const intl = useIntl();
const dispatch = useDispatch();
const selectedCount = selectedItems.length;
const handleAcceptAll = useCallback(() => {
dispatch(openModal({
modalType: 'CONFIRM',
modalProps: {
title: intl.formatMessage(messages.confirmAcceptAllTitle),
message: intl.formatMessage(messages.confirmAcceptAllMessage, { count: selectedItems.length }),
confirm: intl.formatMessage(messages.confirmAcceptAllButton),
onConfirm: () =>
dispatch(acceptNotificationRequests(selectedItems)),
},
}));
}, [dispatch, intl, selectedItems]);
const handleDismissAll = useCallback(() => {
dispatch(openModal({
modalType: 'CONFIRM',
modalProps: {
title: intl.formatMessage(messages.confirmDismissAllTitle),
message: intl.formatMessage(messages.confirmDismissAllMessage, { count: selectedItems.length }),
confirm: intl.formatMessage(messages.confirmDismissAllButton),
onConfirm: () =>
dispatch(dismissNotificationRequests(selectedItems)),
},
}));
}, [dispatch, intl, selectedItems]);
const handleToggleSelectionMode = useCallback(() => {
setSelectionMode((mode) => !mode);
}, [setSelectionMode]);
const menu = selectedCount === 0 ?
[
{ text: intl.formatMessage(messages.acceptAll), action: handleAcceptAll },
{ text: intl.formatMessage(messages.dismissAll), action: handleDismissAll },
] : [
{ text: intl.formatMessage(messages.acceptMultiple, { count: selectedCount }), action: handleAcceptAll },
{ text: intl.formatMessage(messages.dismissMultiple, { count: selectedCount }), action: handleDismissAll },
];
return (
<div className='column-header__select-row'>
{selectionMode && (
<div className='column-header__select-row__checkbox'>
<CheckBox checked={selectAllChecked} indeterminate={selectedCount > 0 && !selectAllChecked} onChange={toggleSelectAll} />
</div>
)}
<div className='column-header__select-row__selection-mode'>
<button className='text-btn' tabIndex={0} onClick={handleToggleSelectionMode}>
{selectionMode ? (
<FormattedMessage id='notification_requests.exit_selection_mode' defaultMessage='Cancel' />
) :
(
<FormattedMessage id='notification_requests.enter_selection_mode' defaultMessage='Select' />
)}
</button>
</div>
{selectedCount > 0 &&
<div className='column-header__select-row__selected-count'>
{selectedCount} selected
</div>
}
<div className='column-header__select-row__actions'>
<DropdownMenuContainer
items={menu}
icons='ellipsis-h'
iconComponent={MoreHorizIcon}
direction='right'
title={intl.formatMessage(messages.more)}
/>
</div>
</div>
);
};
SelectRow.propTypes = {
selectAllChecked: PropTypes.func.isRequired,
toggleSelectAll: PropTypes.func.isRequired,
selectedItems: PropTypes.arrayOf(PropTypes.string).isRequired,
selectionMode: PropTypes.bool,
setSelectionMode: PropTypes.func.isRequired,
};
export const NotificationRequests = ({ multiColumn }) => {
const columnRef = useRef();
const intl = useIntl();
@ -63,10 +166,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 +217,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 +239,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>

View File

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

View File

@ -4525,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;
@ -8017,10 +8047,19 @@ img.modal-warning {
flex: 0 0 auto;
border-radius: 50%;
&.checked {
&.checked,
&.indeterminate {
border-color: $ui-highlight-color;
}
&::before {
.icon {
width: 18px;
height: 18px;
}
}
}
.radio-button.checked::before {
position: absolute;
left: 2px;
top: 2px;
@ -8031,14 +8070,6 @@ img.modal-warning {
height: 12px;
background: $ui-highlight-color;
}
}
.icon {
width: 18px;
height: 18px;
}
}
}
.check-box {
&__input {
@ -8046,13 +8077,10 @@ img.modal-warning {
height: 18px;
border-radius: 2px;
&.checked {
&.checked,
&.indeterminate {
background: $ui-highlight-color;
color: $white;
&::before {
display: none;
}
}
}
}
@ -10780,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;
@ -10843,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 {