diff --git a/app/controllers/api/v1/notifications/policies_controller.rb b/app/controllers/api/v1/notifications/policies_controller.rb index 1ec336f9a5..9d70c283be 100644 --- a/app/controllers/api/v1/notifications/policies_controller.rb +++ b/app/controllers/api/v1/notifications/policies_controller.rb @@ -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 diff --git a/app/controllers/api/v2/notifications/policies_controller.rb b/app/controllers/api/v2/notifications/policies_controller.rb new file mode 100644 index 0000000000..637587967f --- /dev/null +++ b/app/controllers/api/v2/notifications/policies_controller.rb @@ -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 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/api/notification_policies.ts b/app/javascript/flavours/glitch/api/notification_policies.ts index e52ea64f41..1db79a6e74 100644 --- a/app/javascript/flavours/glitch/api/notification_policies.ts +++ b/app/javascript/flavours/glitch/api/notification_policies.ts @@ -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('/v1/notifications/policy'); + apiRequestGet('/v2/notifications/policy'); export const apiUpdateNotificationsPolicy = ( policy: Partial, -) => apiRequestPut('/v1/notifications/policy', policy); +) => apiRequestPut('/v2/notifications/policy', policy); diff --git a/app/javascript/flavours/glitch/api_types/notification_policies.ts b/app/javascript/flavours/glitch/api_types/notification_policies.ts index 0f4a2d132e..1c3970782c 100644 --- a/app/javascript/flavours/glitch/api_types/notification_policies.ts +++ b/app/javascript/flavours/glitch/api_types/notification_policies.ts @@ -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; 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/components/dropdown_selector.tsx b/app/javascript/flavours/glitch/components/dropdown_selector.tsx index f8bf96c634..b86d2d0f80 100644 --- a/app/javascript/flavours/glitch/components/dropdown_selector.tsx +++ b/app/javascript/flavours/glitch/components/dropdown_selector.tsx @@ -13,7 +13,7 @@ const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true; -interface SelectItem { +export interface SelectItem { value: string; icon?: string; iconComponent?: IconProp; 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/components/policy_controls.tsx b/app/javascript/flavours/glitch/features/notifications/components/policy_controls.tsx index 5982db2923..da5d3960b4 100644 --- a/app/javascript/flavours/glitch/features/notifications/components/policy_controls.tsx +++ b/app/javascript/flavours/glitch/features/notifications/components/policy_controls.tsx @@ -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 (

- { defaultMessage='Until you manually approve them' /> - + - { values={{ days: 3 }} /> - + - { values={{ days: 30 }} /> - + - { defaultMessage="Filtered unless it's in reply to your own mention or if you follow the sender" /> - + - + { defaultMessage='Limited by server moderators' /> - +
); diff --git a/app/javascript/flavours/glitch/features/notifications/components/select_with_label.tsx b/app/javascript/flavours/glitch/features/notifications/components/select_with_label.tsx new file mode 100644 index 0000000000..dbdfbdffa6 --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/components/select_with_label.tsx @@ -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 = ({ + value, + options, + disabled, + onChange, + placement: initialPlacement = 'bottom-end', +}) => { + const activeElementRef = useRef(null); + const containerRef = useRef(null); + const [isOpen, setOpen] = useState(false); + const [placement, setPlacement] = useState(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) => { + if (state.placement) setPlacement(state.placement); + }, + [setPlacement], + ); + + const valueOption = options.find((item) => item.value === value); + + return ( +
+ + + + {({ props, placement }) => ( +
+
+ +
+
+ )} +
+
+ ); +}; + +interface Props { + value: string; + options: SelectItem[]; + disabled?: boolean; + onChange: (value: string) => void; +} + +export const SelectWithLabel: React.FC> = ({ + value, + options, + disabled, + children, + onChange, +}) => { + return ( + + ); +}; diff --git a/app/javascript/flavours/glitch/features/notifications/requests.jsx b/app/javascript/flavours/glitch/features/notifications/requests.jsx index 82baffb90e..6db16a42d1 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,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 ( +
+ {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 +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={ + } > @@ -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} /> ))} diff --git a/app/javascript/flavours/glitch/features/notifications_v2/components/notification_mention.tsx b/app/javascript/flavours/glitch/features/notifications_v2/components/notification_mention.tsx index 2868cddde0..91e7445ceb 100644 --- a/app/javascript/flavours/glitch/features/notifications_v2/components/notification_mention.tsx +++ b/app/javascript/flavours/glitch/features/notifications_v2/components/notification_mention.tsx @@ -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 = () => ( + +); + +const privateMentionLabelRenderer: LabelRenderer = () => ( ); -const privateMentionLabelRenderer: LabelRenderer = (values) => ( +const replyLabelRenderer: LabelRenderer = () => ( + +); + +const privateReplyLabelRenderer: LabelRenderer = () => ( ); @@ -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 ( ); diff --git a/app/javascript/flavours/glitch/features/notifications_v2/index.tsx b/app/javascript/flavours/glitch/features/notifications_v2/index.tsx index d58efc4b8f..84fde80008 100644 --- a/app/javascript/flavours/glitch/features/notifications_v2/index.tsx +++ b/app/javascript/flavours/glitch/features/notifications_v2/index.tsx @@ -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'; diff --git a/app/javascript/flavours/glitch/features/status/index.jsx b/app/javascript/flavours/glitch/features/status/index.jsx index 5bc99c6614..cdc567d9c6 100644 --- a/app/javascript/flavours/glitch/features/status/index.jsx +++ b/app/javascript/flavours/glitch/features/status/index.jsx @@ -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 = } />; + } + const handlers = { moveUp: this.handleHotkeyMoveUp, moveDown: this.handleHotkeyMoveDown, @@ -724,6 +729,7 @@ class Status extends ImmutablePureComponent { {descendants} + {remoteHint}
diff --git a/app/javascript/flavours/glitch/features/ui/components/ignore_notifications_modal.jsx b/app/javascript/flavours/glitch/features/ui/components/ignore_notifications_modal.jsx new file mode 100644 index 0000000000..4c7cb21d7d --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/ignore_notifications_modal.jsx @@ -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 = ; + break; + case 'for_not_followers': + title = ; + break; + case 'for_new_accounts': + title = ; + break; + case 'for_private_mentions': + title = ; + break; + case 'for_limited_accounts': + title = ; + break; + } + + return ( +
+
+
+

{title}

+
+ +
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+ +
+ +
+
+ + +
+
+ + +
+ + + + +
+
+
+ ); +}; + +IgnoreNotificationsModal.propTypes = { + filterType: PropTypes.string.isRequired, +}; + +export default IgnoreNotificationsModal; diff --git a/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx b/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx index 0bc393f7e8..64c6b52c31 100644 --- a/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx +++ b/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx @@ -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 { diff --git a/app/javascript/flavours/glitch/features/ui/util/async-components.js b/app/javascript/flavours/glitch/features/ui/util/async-components.js index e334e1a3b6..c7f2e6cff9 100644 --- a/app/javascript/flavours/glitch/features/ui/util/async-components.js +++ b/app/javascript/flavours/glitch/features/ui/util/async-components.js @@ -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'); } 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/selectors/notifications.ts b/app/javascript/flavours/glitch/selectors/notifications.ts index efc2dff68a..34983bd49a 100644 --- a/app/javascript/flavours/glitch/selectors/notifications.ts +++ b/app/javascript/flavours/glitch/selectors/notifications.ts @@ -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, ); diff --git a/app/javascript/flavours/glitch/styles/components.scss b/app/javascript/flavours/glitch/styles/components.scss index cd1b04d284..4e178f576a 100644 --- a/app/javascript/flavours/glitch/styles/components.scss +++ b/app/javascript/flavours/glitch/styles/components.scss @@ -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 { diff --git a/app/javascript/flavours/glitch/styles/emoji_picker.scss b/app/javascript/flavours/glitch/styles/emoji_picker.scss index 3652ad4abb..3189000588 100644 --- a/app/javascript/flavours/glitch/styles/emoji_picker.scss +++ b/app/javascript/flavours/glitch/styles/emoji_picker.scss @@ -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 { diff --git a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss index 73ec36c1b6..4fccbe9867 100644 --- a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss +++ b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss @@ -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%); +} diff --git a/app/javascript/flavours/glitch/styles/mastodon-light/variables.scss b/app/javascript/flavours/glitch/styles/mastodon-light/variables.scss index 9f571b3f26..9d4fd60945 100644 --- a/app/javascript/flavours/glitch/styles/mastodon-light/variables.scss +++ b/app/javascript/flavours/glitch/styles/mastodon-light/variables.scss @@ -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; diff --git a/app/javascript/flavours/glitch/styles/reset.scss b/app/javascript/flavours/glitch/styles/reset.scss index f54ed5bc79..903b6c804f 100644 --- a/app/javascript/flavours/glitch/styles/reset.scss +++ b/app/javascript/flavours/glitch/styles/reset.scss @@ -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; -} diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js index 48afb003ad..7d6a9d5a32 100644 --- a/app/javascript/mastodon/actions/notifications.js +++ b/app/javascript/mastodon/actions/notifications.js @@ -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 }; diff --git a/app/javascript/mastodon/api/notification_policies.ts b/app/javascript/mastodon/api/notification_policies.ts index 4032134fb5..7747397556 100644 --- a/app/javascript/mastodon/api/notification_policies.ts +++ b/app/javascript/mastodon/api/notification_policies.ts @@ -2,8 +2,8 @@ import { apiRequestGet, apiRequestPut } from 'mastodon/api'; import type { NotificationPolicyJSON } from 'mastodon/api_types/notification_policies'; export const apiGetNotificationPolicy = () => - apiRequestGet('/v1/notifications/policy'); + apiRequestGet('/v2/notifications/policy'); export const apiUpdateNotificationsPolicy = ( policy: Partial, -) => apiRequestPut('/v1/notifications/policy', policy); +) => apiRequestPut('/v2/notifications/policy', policy); diff --git a/app/javascript/mastodon/api_types/notification_policies.ts b/app/javascript/mastodon/api_types/notification_policies.ts index 0f4a2d132e..1c3970782c 100644 --- a/app/javascript/mastodon/api_types/notification_policies.ts +++ b/app/javascript/mastodon/api_types/notification_policies.ts @@ -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; diff --git a/app/javascript/mastodon/components/check_box.tsx b/app/javascript/mastodon/components/check_box.tsx index 7da8ef0ac5..9bd137abf5 100644 --- a/app/javascript/mastodon/components/check_box.tsx +++ b/app/javascript/mastodon/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/mastodon/components/dropdown_selector.tsx b/app/javascript/mastodon/components/dropdown_selector.tsx index f8bf96c634..b86d2d0f80 100644 --- a/app/javascript/mastodon/components/dropdown_selector.tsx +++ b/app/javascript/mastodon/components/dropdown_selector.tsx @@ -13,7 +13,7 @@ const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true; -interface SelectItem { +export interface SelectItem { value: string; icon?: string; iconComponent?: IconProp; diff --git a/app/javascript/mastodon/features/notifications/components/notification_request.jsx b/app/javascript/mastodon/features/notifications/components/notification_request.jsx index fc96bd2ee7..2f378942bc 100644 --- a/app/javascript/mastodon/features/notifications/components/notification_request.jsx +++ b/app/javascript/mastodon/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 '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 ( -
- + /* 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/mastodon/features/notifications/components/policy_controls.tsx b/app/javascript/mastodon/features/notifications/components/policy_controls.tsx index d6bc412994..032c0ea483 100644 --- a/app/javascript/mastodon/features/notifications/components/policy_controls.tsx +++ b/app/javascript/mastodon/features/notifications/components/policy_controls.tsx @@ -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 (

- { defaultMessage='Until you manually approve them' /> - + - { values={{ days: 3 }} /> - + - { values={{ days: 30 }} /> - + - { defaultMessage="Filtered unless it's in reply to your own mention or if you follow the sender" /> - + - + { defaultMessage='Limited by server moderators' /> - +
); diff --git a/app/javascript/mastodon/features/notifications/components/select_with_label.tsx b/app/javascript/mastodon/features/notifications/components/select_with_label.tsx new file mode 100644 index 0000000000..413267c0f8 --- /dev/null +++ b/app/javascript/mastodon/features/notifications/components/select_with_label.tsx @@ -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 = ({ + value, + options, + disabled, + onChange, + placement: initialPlacement = 'bottom-end', +}) => { + const activeElementRef = useRef(null); + const containerRef = useRef(null); + const [isOpen, setOpen] = useState(false); + const [placement, setPlacement] = useState(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) => { + if (state.placement) setPlacement(state.placement); + }, + [setPlacement], + ); + + const valueOption = options.find((item) => item.value === value); + + return ( +
+ + + + {({ props, placement }) => ( +
+
+ +
+
+ )} +
+
+ ); +}; + +interface Props { + value: string; + options: SelectItem[]; + disabled?: boolean; + onChange: (value: string) => void; +} + +export const SelectWithLabel: React.FC> = ({ + value, + options, + disabled, + children, + onChange, +}) => { + return ( + + ); +}; diff --git a/app/javascript/mastodon/features/notifications/requests.jsx b/app/javascript/mastodon/features/notifications/requests.jsx index 2fe8dc2b6c..f323bda4fb 100644 --- a/app/javascript/mastodon/features/notifications/requests.jsx +++ b/app/javascript/mastodon/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 '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 ( +
+ {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 +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={ + } > @@ -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} /> ))} diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_mention.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_mention.tsx index f8d646b07e..b7cd995118 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/notification_mention.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/notification_mention.tsx @@ -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 = () => ( + +); + +const privateMentionLabelRenderer: LabelRenderer = () => ( ); -const privateMentionLabelRenderer: LabelRenderer = (values) => ( +const replyLabelRenderer: LabelRenderer = () => ( + +); + +const privateReplyLabelRenderer: LabelRenderer = () => ( ); @@ -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 ( ); diff --git a/app/javascript/mastodon/features/notifications_v2/index.tsx b/app/javascript/mastodon/features/notifications_v2/index.tsx index 21afd9516e..63e602bdcc 100644 --- a/app/javascript/mastodon/features/notifications_v2/index.tsx +++ b/app/javascript/mastodon/features/notifications_v2/index.tsx @@ -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'; diff --git a/app/javascript/mastodon/features/status/index.jsx b/app/javascript/mastodon/features/status/index.jsx index 7f3044c5c9..8f36c44271 100644 --- a/app/javascript/mastodon/features/status/index.jsx +++ b/app/javascript/mastodon/features/status/index.jsx @@ -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 = } />; + } + const handlers = { moveUp: this.handleHotkeyMoveUp, moveDown: this.handleHotkeyMoveDown, @@ -695,6 +700,7 @@ class Status extends ImmutablePureComponent { {descendants} + {remoteHint}
diff --git a/app/javascript/mastodon/features/ui/components/ignore_notifications_modal.jsx b/app/javascript/mastodon/features/ui/components/ignore_notifications_modal.jsx new file mode 100644 index 0000000000..b163b8ce47 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/ignore_notifications_modal.jsx @@ -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 = ; + break; + case 'for_not_followers': + title = ; + break; + case 'for_new_accounts': + title = ; + break; + case 'for_private_mentions': + title = ; + break; + case 'for_limited_accounts': + title = ; + break; + } + + return ( +
+
+
+

{title}

+
+ +
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+ +
+ +
+
+ + +
+
+ + +
+ + + + +
+
+
+ ); +}; + +IgnoreNotificationsModal.propTypes = { + filterType: PropTypes.string.isRequired, +}; + +export default IgnoreNotificationsModal; diff --git a/app/javascript/mastodon/features/ui/components/modal_root.jsx b/app/javascript/mastodon/features/ui/components/modal_root.jsx index 3e900a0667..64933fd1ae 100644 --- a/app/javascript/mastodon/features/ui/components/modal_root.jsx +++ b/app/javascript/mastodon/features/ui/components/modal_root.jsx @@ -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 { diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index 7c4372d5a6..7e9a7af00a 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -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'); } diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 8db48c1a28..fae630a27f 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -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", diff --git a/app/javascript/mastodon/reducers/notification_requests.js b/app/javascript/mastodon/reducers/notification_requests.js index 1aaf167fa6..f73c641965 100644 --- a/app/javascript/mastodon/reducers/notification_requests.js +++ b/app/javascript/mastodon/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/mastodon/selectors/notifications.ts b/app/javascript/mastodon/selectors/notifications.ts index 962dedd650..ea640406ea 100644 --- a/app/javascript/mastodon/selectors/notifications.ts +++ b/app/javascript/mastodon/selectors/notifications.ts @@ -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, ); diff --git a/app/javascript/material-icons/400-24px/check_indeterminate_small-fill.svg b/app/javascript/material-icons/400-24px/check_indeterminate_small-fill.svg new file mode 100644 index 0000000000..d78d33e656 --- /dev/null +++ b/app/javascript/material-icons/400-24px/check_indeterminate_small-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/material-icons/400-24px/check_indeterminate_small.svg b/app/javascript/material-icons/400-24px/check_indeterminate_small.svg new file mode 100644 index 0000000000..d78d33e656 --- /dev/null +++ b/app/javascript/material-icons/400-24px/check_indeterminate_small.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/material-icons/400-24px/person_alert-fill.svg b/app/javascript/material-icons/400-24px/person_alert-fill.svg new file mode 100644 index 0000000000..ddbecc6053 --- /dev/null +++ b/app/javascript/material-icons/400-24px/person_alert-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/material-icons/400-24px/person_alert.svg b/app/javascript/material-icons/400-24px/person_alert.svg new file mode 100644 index 0000000000..292ea32154 --- /dev/null +++ b/app/javascript/material-icons/400-24px/person_alert.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/material-icons/400-24px/shield_question-fill.svg b/app/javascript/material-icons/400-24px/shield_question-fill.svg new file mode 100644 index 0000000000..c647567a00 --- /dev/null +++ b/app/javascript/material-icons/400-24px/shield_question-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/material-icons/400-24px/shield_question.svg b/app/javascript/material-icons/400-24px/shield_question.svg new file mode 100644 index 0000000000..342ac0800e --- /dev/null +++ b/app/javascript/material-icons/400-24px/shield_question.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/styles/mastodon-light/diff.scss b/app/javascript/styles/mastodon-light/diff.scss index 5684a99e51..1f282605ed 100644 --- a/app/javascript/styles/mastodon-light/diff.scss +++ b/app/javascript/styles/mastodon-light/diff.scss @@ -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%); +} diff --git a/app/javascript/styles/mastodon-light/variables.scss b/app/javascript/styles/mastodon-light/variables.scss index 9f571b3f26..9d4fd60945 100644 --- a/app/javascript/styles/mastodon-light/variables.scss +++ b/app/javascript/styles/mastodon-light/variables.scss @@ -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; diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 92690053d6..0620dd3af2 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -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 { diff --git a/app/javascript/styles/mastodon/emoji_picker.scss b/app/javascript/styles/mastodon/emoji_picker.scss index 3652ad4abb..3189000588 100644 --- a/app/javascript/styles/mastodon/emoji_picker.scss +++ b/app/javascript/styles/mastodon/emoji_picker.scss @@ -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 { diff --git a/app/javascript/styles/mastodon/reset.scss b/app/javascript/styles/mastodon/reset.scss index f54ed5bc79..903b6c804f 100644 --- a/app/javascript/styles/mastodon/reset.scss +++ b/app/javascript/styles/mastodon/reset.scss @@ -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; -} diff --git a/app/lib/link_details_extractor.rb b/app/lib/link_details_extractor.rb index 6929fc1b0f..bd78aef7a9 100644 --- a/app/lib/link_details_extractor.rb +++ b/app/lib/link_details_extractor.rb @@ -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 diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb index bf7d2c0272..9df1aba387 100644 --- a/app/models/media_attachment.rb +++ b/app/models/media_attachment.rb @@ -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 diff --git a/app/models/notification_policy.rb b/app/models/notification_policy.rb index 2bb58004e3..3b16f33d88 100644 --- a/app/models/notification_policy.rb +++ b/app/models/notification_policy.rb @@ -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 diff --git a/app/serializers/rest/notification_policy_serializer.rb b/app/serializers/rest/notification_policy_serializer.rb index 8bf85250fa..3902c1a04a 100644 --- a/app/serializers/rest/notification_policy_serializer.rb +++ b/app/serializers/rest/notification_policy_serializer.rb @@ -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 diff --git a/app/serializers/rest/v1/notification_policy_serializer.rb b/app/serializers/rest/v1/notification_policy_serializer.rb new file mode 100644 index 0000000000..e1bbdc44ff --- /dev/null +++ b/app/serializers/rest/v1/notification_policy_serializer.rb @@ -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 diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb index b0bef8cd65..788381fe6b 100644 --- a/app/services/notify_service.rb +++ b/app/services/notify_service.rb @@ -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? diff --git a/app/views/admin/trends/links/_preview_card.html.haml b/app/views/admin/trends/links/_preview_card.html.haml index ee3774790c..49e0dd3fca 100644 --- a/app/views/admin/trends/links/_preview_card.html.haml +++ b/app/views/admin/trends/links/_preview_card.html.haml @@ -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? diff --git a/app/views/admin/trends/links/index.html.haml b/app/views/admin/trends/links/index.html.haml index 647c24b1e9..e54acd656f 100644 --- a/app/views/admin/trends/links/index.html.haml +++ b/app/views/admin/trends/links/index.html.haml @@ -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 diff --git a/app/views/admin/trends/statuses/index.html.haml b/app/views/admin/trends/statuses/index.html.haml index 4713f8c2ae..f9238dee46 100644 --- a/app/views/admin/trends/statuses/index.html.haml +++ b/app/views/admin/trends/statuses/index.html.haml @@ -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 diff --git a/app/views/admin/trends/tags/index.html.haml b/app/views/admin/trends/tags/index.html.haml index 3a44cf3a70..480877456f 100644 --- a/app/views/admin/trends/tags/index.html.haml +++ b/app/views/admin/trends/tags/index.html.haml @@ -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 diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb index 14fab7ecda..b4eaab1daa 100644 --- a/config/initializers/rack_attack.rb +++ b/config/initializers/rack_attack.rb @@ -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| diff --git a/config/locales/en.yml b/config/locales/en.yml index aab8db1815..771bada520 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -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 diff --git a/config/routes/api.rb b/config/routes/api.rb index e20d8cc301..2267dc9b97 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -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 diff --git a/db/migrate/20240808114841_add_new_notification_policies.rb b/db/migrate/20240808114841_add_new_notification_policies.rb new file mode 100644 index 0000000000..9087ee35dc --- /dev/null +++ b/db/migrate/20240808114841_add_new_notification_policies.rb @@ -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 diff --git a/db/migrate/20240808124338_migrate_notifications_policy_v2.rb b/db/migrate/20240808124338_migrate_notifications_policy_v2.rb new file mode 100644 index 0000000000..2e0684826a --- /dev/null +++ b/db/migrate/20240808124338_migrate_notifications_policy_v2.rb @@ -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 diff --git a/db/post_migrate/20240808124339_post_deployment_migrate_notifications_policy_v2.rb b/db/post_migrate/20240808124339_post_deployment_migrate_notifications_policy_v2.rb new file mode 100644 index 0000000000..eb0c909729 --- /dev/null +++ b/db/post_migrate/20240808124339_post_deployment_migrate_notifications_policy_v2.rb @@ -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 diff --git a/db/post_migrate/20240808125420_drop_old_policies_from_notifications_policy.rb b/db/post_migrate/20240808125420_drop_old_policies_from_notifications_policy.rb new file mode 100644 index 0000000000..99ab1e4344 --- /dev/null +++ b/db/post_migrate/20240808125420_drop_old_policies_from_notifications_policy.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 8c67b8469c..ade9a6cd2e 100644 --- a/db/schema.rb +++ b/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 diff --git a/lib/tasks/tests.rake b/lib/tasks/tests.rake index c8e4dc31cd..cb7fce3139 100644 --- a/lib/tasks/tests.rake +++ b/lib/tasks/tests.rake @@ -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 diff --git a/package.json b/package.json index 0fc529d8ba..7801f836e2 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/spec/lib/link_details_extractor_spec.rb b/spec/lib/link_details_extractor_spec.rb index 7ceb6f511d..b1e5cedced 100644 --- a/spec/lib/link_details_extractor_spec.rb +++ b/spec/lib/link_details_extractor_spec.rb @@ -79,16 +79,6 @@ RSpec.describe LinkDetailsExtractor do }, }.to_json end - let(:html) { <<~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 } + + + + + + + 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 diff --git a/spec/models/media_attachment_spec.rb b/spec/models/media_attachment_spec.rb index 3142b291fb..3297387ff7 100644 --- a/spec/models/media_attachment_spec.rb +++ b/spec/models/media_attachment_spec.rb @@ -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 diff --git a/spec/requests/api/v1/notifications/policies_spec.rb b/spec/requests/api/v1/notifications/policies_spec.rb index cbd4499772..a73d4217be 100644 --- a/spec/requests/api/v1/notifications/policies_spec.rb +++ b/spec/requests/api/v1/notifications/policies_spec.rb @@ -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( diff --git a/spec/requests/api/v2/notifications/policies_spec.rb b/spec/requests/api/v2/notifications/policies_spec.rb new file mode 100644 index 0000000000..f9860b5fb4 --- /dev/null +++ b/spec/requests/api/v2/notifications/policies_spec.rb @@ -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 diff --git a/spec/services/notify_service_spec.rb b/spec/services/notify_service_spec.rb index d64cfe5907..935b94c709 100644 --- a/spec/services/notify_service_spec.rb +++ b/spec/services/notify_service_spec.rb @@ -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 diff --git a/stylelint.config.js b/stylelint.config.js index 0e50d6c14f..dc544d6756 100644 --- a/stylelint.config.js +++ b/stylelint.config.js @@ -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', diff --git a/yarn.lock b/yarn.lock index 0058b81208..969e91c172 100644 --- a/yarn.lock +++ b/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"