From b6961d08abafdee930040ec25265acfee7ae6019 Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 9 Aug 2024 16:56:39 +0200 Subject: [PATCH] [Glitch] Add ability to report, block and mute from notification requests list Port 658addcbf783f6baa922d11c9524ebb9ddbcbc59 to glitch-soc Co-authored-by: Renaud Chaput Signed-off-by: Claire --- .../flavours/glitch/actions/notifications.js | 64 ++++++++ .../flavours/glitch/components/check_box.tsx | 13 +- .../components/notification_request.jsx | 75 ++++++++- .../features/notifications/requests.jsx | 144 +++++++++++++++++- .../glitch/reducers/notification_requests.js | 5 + .../flavours/glitch/styles/components.scss | 111 +++++++++++--- 6 files changed, 380 insertions(+), 32 deletions(-) diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js index fc879dce64..86e2d6eb4e 100644 --- a/app/javascript/flavours/glitch/actions/notifications.js +++ b/app/javascript/flavours/glitch/actions/notifications.js @@ -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 }; diff --git a/app/javascript/flavours/glitch/components/check_box.tsx b/app/javascript/flavours/glitch/components/check_box.tsx index 7da8ef0ac5..9bd137abf5 100644 --- a/app/javascript/flavours/glitch/components/check_box.tsx +++ b/app/javascript/flavours/glitch/components/check_box.tsx @@ -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) => void; label: React.ReactNode; @@ -16,6 +18,7 @@ export const CheckBox: React.FC = ({ name, value, checked, + indeterminate, onChange, label, }) => { @@ -29,8 +32,14 @@ export const CheckBox: React.FC = ({ onChange={onChange} /> - - {checked && } + + {indeterminate ? ( + + ) : ( + checked && + )} {label} diff --git a/app/javascript/flavours/glitch/features/notifications/components/notification_request.jsx b/app/javascript/flavours/glitch/features/notifications/components/notification_request.jsx index 3b32955348..b0db186b72 100644 --- a/app/javascript/flavours/glitch/features/notifications/components/notification_request.jsx +++ b/app/javascript/flavours/glitch/features/notifications/components/notification_request.jsx @@ -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 ( -
- + /* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- this is just a minor affordance, but we will need a comprehensive accessibility pass */ +
+
+ +
+
@@ -51,7 +105,13 @@ export const NotificationRequest = ({ id, accountId, notificationsCount }) => {
- +
); @@ -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, }; diff --git a/app/javascript/flavours/glitch/features/notifications/requests.jsx b/app/javascript/flavours/glitch/features/notifications/requests.jsx index 82baffb90e..f8f75023ad 100644 --- a/app/javascript/flavours/glitch/features/notifications/requests.jsx +++ b/app/javascript/flavours/glitch/features/notifications/requests.jsx @@ -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 ( +
+ {selectionMode && ( +
+ 0 && !selectAllChecked} onChange={toggleSelectAll} /> +
+ )} +
+ +
+ {selectedCount > 0 && +
+ {selectedCount} selected +
+ } +
+ +
+
+ ); +}; + +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={ + } > @@ -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} /> ))} diff --git a/app/javascript/flavours/glitch/reducers/notification_requests.js b/app/javascript/flavours/glitch/reducers/notification_requests.js index f10f982d2a..0a033682da 100644 --- a/app/javascript/flavours/glitch/reducers/notification_requests.js +++ b/app/javascript/flavours/glitch/reducers/notification_requests.js @@ -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: diff --git a/app/javascript/flavours/glitch/styles/components.scss b/app/javascript/flavours/glitch/styles/components.scss index ebae4e8bcc..4e178f576a 100644 --- a/app/javascript/flavours/glitch/styles/components.scss +++ b/app/javascript/flavours/glitch/styles/components.scss @@ -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,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 { @@ -8040,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; - } } } } @@ -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 {