1
0
Fork 0
forked from gitea/nas

Merge remote-tracking branch 'parent/main' into upstream-20240813

This commit is contained in:
KMY 2024-08-13 07:01:38 +09:00
commit e7ccc0539f
358 changed files with 4653 additions and 4261 deletions

View file

@ -151,7 +151,7 @@ class AccountNote extends ImmutablePureComponent {
return (
<div className='account__header__account-note'>
<label htmlFor={`account-note-${account.get('id')}`}>
<FormattedMessage id='account.account_note_header' defaultMessage='Note' /> <InlineAlert show={saved} />
<FormattedMessage id='account.account_note_header' defaultMessage='Personal note' /> <InlineAlert show={saved} />
</label>
<Textarea

View file

@ -18,11 +18,10 @@ import PublicIcon from '@/material-icons/400-24px/public.svg?react';
import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react';
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
import LimitedIcon from '@/material-icons/400-24px/shield.svg?react';
import { DropdownSelector } from 'mastodon/components/dropdown_selector';
import { Icon } from 'mastodon/components/icon';
import { enabledVisibilites } from 'mastodon/initial_state';
import { PrivacyDropdownMenu } from './privacy_dropdown_menu';
const messages = defineMessages({
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
public_long: { id: 'privacy.public.long', defaultMessage: 'Anyone on and off Mastodon' },
@ -135,7 +134,7 @@ class PrivacyDropdown extends PureComponent {
const { intl: { formatMessage } } = this.props;
this.dynamicOptions = [
{ icon: 'reply', iconComponent: ReplyIcon, value: 'reply', text: formatMessage(messages.reply_short), meta: formatMessage(messages.reply_long), extra: formatMessage(messages.limited_short), extraIcomComponent: LimitedIcon },
{ icon: 'reply', iconComponent: ReplyIcon, value: 'reply', text: formatMessage(messages.reply_short), meta: formatMessage(messages.reply_long), extra: formatMessage(messages.limited_short), extraIconComponent: LimitedIcon },
{ icon: 'ban', iconComponent: BlockIcon, value: 'banned', text: formatMessage(messages.banned_short), meta: formatMessage(messages.banned_long) },
];
@ -145,8 +144,8 @@ class PrivacyDropdown extends PureComponent {
{ icon: 'key', iconComponent: LoginIcon, value: 'login', text: formatMessage(messages.login_short), meta: formatMessage(messages.login_long) },
{ icon: 'unlock', iconComponent: QuietTimeIcon, value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long), extra: formatMessage(messages.unlisted_extra) },
{ icon: 'lock', iconComponent: LockIcon, value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) },
{ icon: 'exchange', iconComponent: MutualIcon, value: 'mutual', text: formatMessage(messages.mutual_short), meta: formatMessage(messages.mutual_long), extra: formatMessage(messages.limited_short), extraIcomComponent: LimitedIcon },
{ icon: 'user-circle', iconComponent: CircleIcon, value: 'circle', text: formatMessage(messages.circle_short), meta: formatMessage(messages.circle_long), extra: formatMessage(messages.limited_short), extraIcomComponent: LimitedIcon },
{ icon: 'exchange', iconComponent: MutualIcon, value: 'mutual', text: formatMessage(messages.mutual_short), meta: formatMessage(messages.mutual_long), extra: formatMessage(messages.limited_short), extraIconComponent: LimitedIcon },
{ icon: 'user-circle', iconComponent: CircleIcon, value: 'circle', text: formatMessage(messages.circle_short), meta: formatMessage(messages.circle_long), extra: formatMessage(messages.limited_short), extraIconComponent: LimitedIcon },
{ icon: 'at', iconComponent: AlternateEmailIcon, value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) },
...this.dynamicOptions,
];
@ -219,7 +218,7 @@ class PrivacyDropdown extends PureComponent {
{({ props, placement }) => (
<div {...props}>
<div className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}>
<PrivacyDropdownMenu
<DropdownSelector
items={this.options}
value={value}
onClose={this.handleClose}

View file

@ -1,128 +0,0 @@
import PropTypes from 'prop-types';
import { useCallback, useEffect, useRef, useState } from 'react';
import classNames from 'classnames';
import { supportsPassiveEvents } from 'detect-passive-events';
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
import { Icon } from 'mastodon/components/icon';
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;
export const PrivacyDropdownMenu = ({ style, items, value, onClose, onChange }) => {
const nodeRef = useRef(null);
const focusedItemRef = useRef(null);
const [currentValue, setCurrentValue] = useState(value);
const handleDocumentClick = useCallback((e) => {
if (nodeRef.current && !nodeRef.current.contains(e.target)) {
onClose();
e.stopPropagation();
}
}, [nodeRef, onClose]);
const handleClick = useCallback((e) => {
const value = e.currentTarget.getAttribute('data-index');
e.preventDefault();
onClose();
onChange(value);
}, [onClose, onChange]);
const handleKeyDown = useCallback((e) => {
const value = e.currentTarget.getAttribute('data-index');
const index = items.findIndex(item => (item.value === value));
let element = null;
switch (e.key) {
case 'Escape':
onClose();
break;
case ' ':
case 'Enter':
handleClick(e);
break;
case 'ArrowDown':
element = nodeRef.current.childNodes[index + 1] || nodeRef.current.firstChild;
break;
case 'ArrowUp':
element = nodeRef.current.childNodes[index - 1] || nodeRef.current.lastChild;
break;
case 'Tab':
if (e.shiftKey) {
element = nodeRef.current.childNodes[index + 1] || nodeRef.current.firstChild;
} else {
element = nodeRef.current.childNodes[index - 1] || nodeRef.current.lastChild;
}
break;
case 'Home':
element = nodeRef.current.firstChild;
break;
case 'End':
element = nodeRef.current.lastChild;
break;
}
if (element) {
element.focus();
setCurrentValue(element.getAttribute('data-index'));
e.preventDefault();
e.stopPropagation();
}
}, [nodeRef, items, onClose, handleClick, setCurrentValue]);
useEffect(() => {
document.addEventListener('click', handleDocumentClick, { capture: true });
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
focusedItemRef.current?.focus({ preventScroll: true });
return () => {
document.removeEventListener('click', handleDocumentClick, { capture: true });
document.removeEventListener('touchend', handleDocumentClick, listenerOptions);
};
}, [handleDocumentClick]);
return (
<ul style={{ ...style }} role='listbox' ref={nodeRef}>
{items.map(item => (
<li
role='option'
tabIndex={0}
key={item.value}
data-index={item.value}
onKeyDown={handleKeyDown}
onClick={handleClick}
className={classNames('privacy-dropdown__option', { active: item.value === currentValue })}
aria-selected={item.value === currentValue}
ref={item.value === currentValue ? focusedItemRef : null}
>
<div className='privacy-dropdown__option__icon'>
<Icon id={item.icon} icon={item.iconComponent} />
</div>
<div className='privacy-dropdown__option__content'>
<strong>{item.text}</strong>
{item.meta}
</div>
{item.extra && (
<div className='privacy-dropdown__option__additional' title={item.extra}>
<Icon id='info-circle' icon={item.extraIcomComponent ?? InfoIcon} />
</div>
)}
</li>
))}
</ul>
);
};
PrivacyDropdownMenu.propTypes = {
style: PropTypes.object,
items: PropTypes.array.isRequired,
value: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
};

View file

@ -3,15 +3,21 @@ import { useCallback } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import classNames from 'classnames';
import { Link, useHistory } from 'react-router-dom';
import { useSelector, useDispatch } from 'react-redux';
import DeleteIcon from '@/material-icons/400-24px/delete.svg?react';
import DoneIcon from '@/material-icons/400-24px/done.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import { initBlockModal } from 'mastodon/actions/blocks';
import { initMuteModal } from 'mastodon/actions/mutes';
import { acceptNotificationRequest, dismissNotificationRequest } from 'mastodon/actions/notifications';
import { initReport } from 'mastodon/actions/reports';
import { Avatar } from 'mastodon/components/avatar';
import { CheckBox } from 'mastodon/components/check_box';
import { IconButton } from 'mastodon/components/icon_button';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import { makeGetAccount } from 'mastodon/selectors';
import { toCappedNumber } from 'mastodon/utils/numbers';
@ -20,12 +26,18 @@ const getAccount = makeGetAccount();
const messages = defineMessages({
accept: { id: 'notification_requests.accept', defaultMessage: 'Accept' },
dismiss: { id: 'notification_requests.dismiss', defaultMessage: 'Dismiss' },
view: { id: 'notification_requests.view', defaultMessage: 'View notifications' },
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
block: { id: 'account.block', defaultMessage: 'Block @{name}' },
report: { id: 'status.report', defaultMessage: 'Report @{name}' },
more: { id: 'status.more', defaultMessage: 'More' },
});
export const NotificationRequest = ({ id, accountId, notificationsCount }) => {
export const NotificationRequest = ({ id, accountId, notificationsCount, checked, showCheckbox, toggleCheck }) => {
const dispatch = useDispatch();
const account = useSelector(state => getAccount(state, accountId));
const intl = useIntl();
const { push: historyPush } = useHistory();
const handleDismiss = useCallback(() => {
dispatch(dismissNotificationRequest(id));
@ -35,9 +47,51 @@ export const NotificationRequest = ({ id, accountId, notificationsCount }) => {
dispatch(acceptNotificationRequest(id));
}, [dispatch, id]);
const handleMute = useCallback(() => {
dispatch(initMuteModal(account));
}, [dispatch, account]);
const handleBlock = useCallback(() => {
dispatch(initBlockModal(account));
}, [dispatch, account]);
const handleReport = useCallback(() => {
dispatch(initReport(account));
}, [dispatch, account]);
const handleView = useCallback(() => {
historyPush(`/notifications/requests/${id}`);
}, [historyPush, id]);
const menu = [
{ text: intl.formatMessage(messages.view), action: handleView },
null,
{ text: intl.formatMessage(messages.accept), action: handleAccept },
null,
{ text: intl.formatMessage(messages.mute, { name: account.username }), action: handleMute, dangerous: true },
{ text: intl.formatMessage(messages.block, { name: account.username }), action: handleBlock, dangerous: true },
{ text: intl.formatMessage(messages.report, { name: account.username }), action: handleReport, dangerous: true },
];
const handleCheck = useCallback(() => {
toggleCheck(id);
}, [toggleCheck, id]);
const handleClick = useCallback((e) => {
if (showCheckbox) {
toggleCheck(id);
e.preventDefault();
e.stopPropagation();
}
}, [toggleCheck, id, showCheckbox]);
return (
<div className='notification-request'>
<Link to={`/notifications/requests/${id}`} className='notification-request__link'>
/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- this is just a minor affordance, but we will need a comprehensive accessibility pass */
<div className={classNames('notification-request', showCheckbox && 'notification-request--forced-checkbox')} onClick={handleClick}>
<div className='notification-request__checkbox' aria-hidden={!showCheckbox}>
<CheckBox checked={checked} onChange={handleCheck} />
</div>
<Link to={`/notifications/requests/${id}`} className='notification-request__link' onClick={handleClick} title={account?.acct}>
<Avatar account={account} size={40} counter={toCappedNumber(notificationsCount)} />
<div className='notification-request__name'>
@ -51,7 +105,13 @@ export const NotificationRequest = ({ id, accountId, notificationsCount }) => {
<div className='notification-request__actions'>
<IconButton iconComponent={DeleteIcon} onClick={handleDismiss} title={intl.formatMessage(messages.dismiss)} />
<IconButton iconComponent={DoneIcon} onClick={handleAccept} title={intl.formatMessage(messages.accept)} />
<DropdownMenuContainer
items={menu}
icons='ellipsis-h'
iconComponent={MoreHorizIcon}
direction='right'
title={intl.formatMessage(messages.more)}
/>
</div>
</div>
);
@ -61,4 +121,7 @@ NotificationRequest.propTypes = {
id: PropTypes.string.isRequired,
accountId: PropTypes.string.isRequired,
notificationsCount: PropTypes.string.isRequired,
checked: PropTypes.bool,
showCheckbox: PropTypes.bool,
toggleCheck: PropTypes.func,
};

View file

@ -1,13 +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';
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(
@ -15,56 +54,74 @@ export const PolicyControls: React.FC = () => {
);
const handleFilterNotFollowing = useCallback(
(checked: boolean) => {
void dispatch(
updateNotificationsPolicy({ filter_not_following: checked }),
);
(value: string) => {
changeFilter(dispatch, 'for_not_following', value);
},
[dispatch],
);
const handleFilterNotFollowers = useCallback(
(checked: boolean) => {
void dispatch(
updateNotificationsPolicy({ filter_not_followers: checked }),
);
(value: string) => {
changeFilter(dispatch, 'for_not_followers', value);
},
[dispatch],
);
const handleFilterNewAccounts = useCallback(
(checked: boolean) => {
void dispatch(
updateNotificationsPolicy({ filter_new_accounts: checked }),
);
(value: string) => {
changeFilter(dispatch, 'for_new_accounts', value);
},
[dispatch],
);
const handleFilterPrivateMentions = useCallback(
(checked: boolean) => {
void dispatch(
updateNotificationsPolicy({ filter_private_mentions: checked }),
);
(value: string) => {
changeFilter(dispatch, 'for_private_mentions', value);
},
[dispatch],
);
const handleFilterLimitedAccounts = useCallback(
(value: string) => {
changeFilter(dispatch, 'for_limited_accounts', value);
},
[dispatch],
);
if (!notificationPolicy) return null;
const options = [
{
value: 'accept',
text: intl.formatMessage(messages.accept),
meta: intl.formatMessage(messages.accept_hint),
},
{
value: 'filter',
text: intl.formatMessage(messages.filter),
meta: intl.formatMessage(messages.filter_hint),
},
{
value: 'drop',
text: intl.formatMessage(messages.drop),
meta: intl.formatMessage(messages.drop_hint),
},
];
return (
<section>
<h3>
<FormattedMessage
id='notifications.policy.title'
defaultMessage='Filter out notifications from…'
defaultMessage='Manage notifications from…'
/>
</h3>
<div className='column-settings__row'>
<CheckboxWithLabel
checked={notificationPolicy.filter_not_following}
<SelectWithLabel
value={notificationPolicy.for_not_following}
onChange={handleFilterNotFollowing}
options={options}
>
<strong>
<FormattedMessage
@ -78,11 +135,12 @@ export const PolicyControls: React.FC = () => {
defaultMessage='Until you manually approve them'
/>
</span>
</CheckboxWithLabel>
</SelectWithLabel>
<CheckboxWithLabel
checked={notificationPolicy.filter_not_followers}
<SelectWithLabel
value={notificationPolicy.for_not_followers}
onChange={handleFilterNotFollowers}
options={options}
>
<strong>
<FormattedMessage
@ -97,11 +155,12 @@ export const PolicyControls: React.FC = () => {
values={{ days: 3 }}
/>
</span>
</CheckboxWithLabel>
</SelectWithLabel>
<CheckboxWithLabel
checked={notificationPolicy.filter_new_accounts}
<SelectWithLabel
value={notificationPolicy.for_new_accounts}
onChange={handleFilterNewAccounts}
options={options}
>
<strong>
<FormattedMessage
@ -116,11 +175,12 @@ export const PolicyControls: React.FC = () => {
values={{ days: 30 }}
/>
</span>
</CheckboxWithLabel>
</SelectWithLabel>
<CheckboxWithLabel
checked={notificationPolicy.filter_private_mentions}
<SelectWithLabel
value={notificationPolicy.for_private_mentions}
onChange={handleFilterPrivateMentions}
options={options}
>
<strong>
<FormattedMessage
@ -134,7 +194,26 @@ export const PolicyControls: React.FC = () => {
defaultMessage="Filtered unless it's in reply to your own mention or if you follow the sender"
/>
</span>
</CheckboxWithLabel>
</SelectWithLabel>
<SelectWithLabel
value={notificationPolicy.for_limited_accounts}
onChange={handleFilterLimitedAccounts}
options={options}
>
<strong>
<FormattedMessage
id='notifications.policy.filter_limited_accounts_title'
defaultMessage='Moderated accounts'
/>
</strong>
<span className='hint'>
<FormattedMessage
id='notifications.policy.filter_limited_accounts_hint'
defaultMessage='Limited by server moderators'
/>
</span>
</SelectWithLabel>
</div>
</section>
);

View file

@ -0,0 +1,153 @@
import type { PropsWithChildren } from 'react';
import { useCallback, useState, useRef } from 'react';
import classNames from 'classnames';
import type { Placement, State as PopperState } from '@popperjs/core';
import Overlay from 'react-overlays/Overlay';
import ArrowDropDownIcon from '@/material-icons/400-24px/arrow_drop_down.svg?react';
import type { SelectItem } from 'mastodon/components/dropdown_selector';
import { DropdownSelector } from 'mastodon/components/dropdown_selector';
import { Icon } from 'mastodon/components/icon';
interface DropdownProps {
value: string;
options: SelectItem[];
disabled?: boolean;
onChange: (value: string) => void;
placement?: Placement;
}
const Dropdown: React.FC<DropdownProps> = ({
value,
options,
disabled,
onChange,
placement: initialPlacement = 'bottom-end',
}) => {
const activeElementRef = useRef<Element | null>(null);
const containerRef = useRef(null);
const [isOpen, setOpen] = useState<boolean>(false);
const [placement, setPlacement] = useState<Placement>(initialPlacement);
const handleToggle = useCallback(() => {
if (
isOpen &&
activeElementRef.current &&
activeElementRef.current instanceof HTMLElement
) {
activeElementRef.current.focus({ preventScroll: true });
}
setOpen(!isOpen);
}, [isOpen, setOpen]);
const handleMouseDown = useCallback(() => {
if (!isOpen) activeElementRef.current = document.activeElement;
}, [isOpen]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
switch (e.key) {
case ' ':
case 'Enter':
if (!isOpen) activeElementRef.current = document.activeElement;
break;
}
},
[isOpen],
);
const handleClose = useCallback(() => {
if (
isOpen &&
activeElementRef.current &&
activeElementRef.current instanceof HTMLElement
)
activeElementRef.current.focus({ preventScroll: true });
setOpen(false);
}, [isOpen]);
const handleOverlayEnter = useCallback(
(state: Partial<PopperState>) => {
if (state.placement) setPlacement(state.placement);
},
[setPlacement],
);
const valueOption = options.find((item) => item.value === value);
return (
<div ref={containerRef}>
<button
type='button'
onClick={handleToggle}
onMouseDown={handleMouseDown}
onKeyDown={handleKeyDown}
disabled={disabled}
className={classNames('dropdown-button', { active: isOpen })}
>
<span className='dropdown-button__label'>{valueOption?.text}</span>
<Icon id='down' icon={ArrowDropDownIcon} />
</button>
<Overlay
show={isOpen}
offset={[5, 5]}
placement={placement}
flip
target={containerRef}
popperConfig={{ strategy: 'fixed', onFirstUpdate: handleOverlayEnter }}
>
{({ props, placement }) => (
<div {...props}>
<div
className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}
>
<DropdownSelector
items={options}
value={value}
onClose={handleClose}
onChange={onChange}
classNamePrefix='privacy-dropdown'
/>
</div>
</div>
)}
</Overlay>
</div>
);
};
interface Props {
value: string;
options: SelectItem[];
disabled?: boolean;
onChange: (value: string) => void;
}
export const SelectWithLabel: React.FC<PropsWithChildren<Props>> = ({
value,
options,
disabled,
children,
onChange,
}) => {
return (
<label className='app-form__toggle'>
<div className='app-form__toggle__label'>{children}</div>
<div className='app-form__toggle__toggle'>
<div>
<Dropdown
value={value}
onChange={onChange}
disabled={disabled}
options={options}
/>
</div>
</div>
</label>
);
};

View file

@ -1,7 +1,7 @@
import PropTypes from 'prop-types';
import { useRef, useCallback, useEffect } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
@ -90,6 +90,23 @@ export const NotificationRequest = ({ multiColumn, params: { id } }) => {
const columnTitle = intl.formatMessage(messages.title, { name: account?.get('display_name') || account?.get('username') });
let explainer = null;
if (account?.limited) {
const isLocal = account.acct.indexOf('@') === -1;
explainer = (
<div className='dismissable-banner'>
<div className='dismissable-banner__message'>
{isLocal ? (
<FormattedMessage id='notification_requests.explainer_for_limited_account' defaultMessage='Notifications from this account have been filtered because the account has been limited by a moderator.' />
) : (
<FormattedMessage id='notification_requests.explainer_for_limited_remote_account' defaultMessage='Notifications from this account have been filtered because the account or its server has been limited by a moderator.' />
)}
</div>
</div>
);
}
return (
<Column bindToDocument={!multiColumn} ref={columnRef} label={columnTitle}>
<ColumnHeader
@ -109,6 +126,7 @@ export const NotificationRequest = ({ multiColumn, params: { id } }) => {
<SensitiveMediaContextProvider hideMediaByDefault>
<ScrollableList
prepend={explainer}
scrollKey={`notification_requests/${id}`}
trackScroll={!multiColumn}
bindToDocument={!multiColumn}

View file

@ -1,5 +1,5 @@
import PropTypes from 'prop-types';
import { useRef, useCallback, useEffect } from 'react';
import { useRef, useCallback, useEffect, useState } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
@ -8,11 +8,15 @@ import { Helmet } from 'react-helmet';
import { useSelector, useDispatch } from 'react-redux';
import InventoryIcon from '@/material-icons/400-24px/inventory_2.svg?react';
import { fetchNotificationRequests, expandNotificationRequests } from 'mastodon/actions/notifications';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import { openModal } from 'mastodon/actions/modal';
import { fetchNotificationRequests, expandNotificationRequests, acceptNotificationRequests, dismissNotificationRequests } from 'mastodon/actions/notifications';
import { changeSetting } from 'mastodon/actions/settings';
import { CheckBox } from 'mastodon/components/check_box';
import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header';
import ScrollableList from 'mastodon/components/scrollable_list';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import { NotificationRequest } from './components/notification_request';
import { PolicyControls } from './components/policy_controls';
@ -20,7 +24,18 @@ import SettingToggle from './components/setting_toggle';
const messages = defineMessages({
title: { id: 'notification_requests.title', defaultMessage: 'Filtered notifications' },
maximize: { id: 'notification_requests.maximize', defaultMessage: 'Maximize' }
maximize: { id: 'notification_requests.maximize', defaultMessage: 'Maximize' },
more: { id: 'status.more', defaultMessage: 'More' },
acceptAll: { id: 'notification_requests.accept_all', defaultMessage: 'Accept all' },
dismissAll: { id: 'notification_requests.dismiss_all', defaultMessage: 'Dismiss all' },
acceptMultiple: { id: 'notification_requests.accept_multiple', defaultMessage: '{count, plural, one {Accept # request} other {Accept # requests}}' },
dismissMultiple: { id: 'notification_requests.dismiss_multiple', defaultMessage: '{count, plural, one {Dismiss # request} other {Dismiss # requests}}' },
confirmAcceptAllTitle: { id: 'notification_requests.confirm_accept_all.title', defaultMessage: 'Accept notification requests?' },
confirmAcceptAllMessage: { id: 'notification_requests.confirm_accept_all.message', defaultMessage: 'You are about to accept {count, plural, one {one notification request} other {# notification requests}}. Are you sure you want to proceed?' },
confirmAcceptAllButton: { id: 'notification_requests.confirm_accept_all.button', defaultMessage: 'Accept all' },
confirmDismissAllTitle: { id: 'notification_requests.confirm_dismiss_all.title', defaultMessage: 'Dismiss notification requests?' },
confirmDismissAllMessage: { id: 'notification_requests.confirm_dismiss_all.message', defaultMessage: "You are about to dismiss {count, plural, one {one notification request} other {# notification requests}}. You won't be able to easily access {count, plural, one {it} other {them}} again. Are you sure you want to proceed?" },
confirmDismissAllButton: { id: 'notification_requests.confirm_dismiss_all.button', defaultMessage: 'Dismiss all' },
});
const ColumnSettings = () => {
@ -55,6 +70,124 @@ const ColumnSettings = () => {
);
};
const SelectRow = ({selectAllChecked, toggleSelectAll, selectedItems, selectionMode, setSelectionMode}) => {
const intl = useIntl();
const dispatch = useDispatch();
const notificationRequests = useSelector(state => state.getIn(['notificationRequests', 'items']));
const selectedCount = selectedItems.length;
const handleAcceptAll = useCallback(() => {
const items = notificationRequests.map(request => request.get('id')).toArray();
dispatch(openModal({
modalType: 'CONFIRM',
modalProps: {
title: intl.formatMessage(messages.confirmAcceptAllTitle),
message: intl.formatMessage(messages.confirmAcceptAllMessage, { count: items.length }),
confirm: intl.formatMessage(messages.confirmAcceptAllButton),
onConfirm: () =>
dispatch(acceptNotificationRequests(items)),
},
}));
}, [dispatch, intl, notificationRequests]);
const handleDismissAll = useCallback(() => {
const items = notificationRequests.map(request => request.get('id')).toArray();
dispatch(openModal({
modalType: 'CONFIRM',
modalProps: {
title: intl.formatMessage(messages.confirmDismissAllTitle),
message: intl.formatMessage(messages.confirmDismissAllMessage, { count: items.length }),
confirm: intl.formatMessage(messages.confirmDismissAllButton),
onConfirm: () =>
dispatch(dismissNotificationRequests(items)),
},
}));
}, [dispatch, intl, notificationRequests]);
const handleAcceptMultiple = useCallback(() => {
dispatch(openModal({
modalType: 'CONFIRM',
modalProps: {
title: intl.formatMessage(messages.confirmAcceptAllTitle),
message: intl.formatMessage(messages.confirmAcceptAllMessage, { count: selectedItems.length }),
confirm: intl.formatMessage(messages.confirmAcceptAllButton),
onConfirm: () =>
dispatch(acceptNotificationRequests(selectedItems)),
},
}));
}, [dispatch, intl, selectedItems]);
const handleDismissMultiple = useCallback(() => {
dispatch(openModal({
modalType: 'CONFIRM',
modalProps: {
title: intl.formatMessage(messages.confirmDismissAllTitle),
message: intl.formatMessage(messages.confirmDismissAllMessage, { count: selectedItems.length }),
confirm: intl.formatMessage(messages.confirmDismissAllButton),
onConfirm: () =>
dispatch(dismissNotificationRequests(selectedItems)),
},
}));
}, [dispatch, intl, selectedItems]);
const handleToggleSelectionMode = useCallback(() => {
setSelectionMode((mode) => !mode);
}, [setSelectionMode]);
const menu = selectedCount === 0 ?
[
{ text: intl.formatMessage(messages.acceptAll), action: handleAcceptAll },
{ text: intl.formatMessage(messages.dismissAll), action: handleDismissAll },
] : [
{ text: intl.formatMessage(messages.acceptMultiple, { count: selectedCount }), action: handleAcceptMultiple },
{ text: intl.formatMessage(messages.dismissMultiple, { count: selectedCount }), action: handleDismissMultiple },
];
return (
<div className='column-header__select-row'>
{selectionMode && (
<div className='column-header__select-row__checkbox'>
<CheckBox checked={selectAllChecked} indeterminate={selectedCount > 0 && !selectAllChecked} onChange={toggleSelectAll} />
</div>
)}
<div className='column-header__select-row__selection-mode'>
<button className='text-btn' tabIndex={0} onClick={handleToggleSelectionMode}>
{selectionMode ? (
<FormattedMessage id='notification_requests.exit_selection_mode' defaultMessage='Cancel' />
) :
(
<FormattedMessage id='notification_requests.enter_selection_mode' defaultMessage='Select' />
)}
</button>
</div>
{selectedCount > 0 &&
<div className='column-header__select-row__selected-count'>
{selectedCount} selected
</div>
}
<div className='column-header__select-row__actions'>
<DropdownMenuContainer
items={menu}
icons='ellipsis-h'
iconComponent={MoreHorizIcon}
direction='right'
title={intl.formatMessage(messages.more)}
/>
</div>
</div>
);
};
SelectRow.propTypes = {
selectAllChecked: PropTypes.func.isRequired,
toggleSelectAll: PropTypes.func.isRequired,
selectedItems: PropTypes.arrayOf(PropTypes.string).isRequired,
selectionMode: PropTypes.bool,
setSelectionMode: PropTypes.func.isRequired,
};
export const NotificationRequests = ({ multiColumn }) => {
const columnRef = useRef();
const intl = useIntl();
@ -63,10 +196,40 @@ export const NotificationRequests = ({ multiColumn }) => {
const notificationRequests = useSelector(state => state.getIn(['notificationRequests', 'items']));
const hasMore = useSelector(state => !!state.getIn(['notificationRequests', 'next']));
const [selectionMode, setSelectionMode] = useState(false);
const [checkedRequestIds, setCheckedRequestIds] = useState([]);
const [selectAllChecked, setSelectAllChecked] = useState(false);
const handleHeaderClick = useCallback(() => {
columnRef.current?.scrollTop();
}, [columnRef]);
const handleCheck = useCallback(id => {
setCheckedRequestIds(ids => {
const position = ids.indexOf(id);
if(position > -1)
ids.splice(position, 1);
else
ids.push(id);
setSelectAllChecked(ids.length === notificationRequests.size);
return [...ids];
});
}, [setCheckedRequestIds, notificationRequests]);
const toggleSelectAll = useCallback(() => {
setSelectAllChecked(checked => {
if(checked)
setCheckedRequestIds([]);
else
setCheckedRequestIds(notificationRequests.map(request => request.get('id')).toArray());
return !checked;
});
}, [notificationRequests]);
const handleLoadMore = useCallback(() => {
dispatch(expandNotificationRequests());
}, [dispatch]);
@ -84,6 +247,8 @@ export const NotificationRequests = ({ multiColumn }) => {
onClick={handleHeaderClick}
multiColumn={multiColumn}
showBackButton
appendContent={
<SelectRow selectionMode={selectionMode} setSelectionMode={setSelectionMode} selectAllChecked={selectAllChecked} toggleSelectAll={toggleSelectAll} selectedItems={checkedRequestIds} />}
>
<ColumnSettings />
</ColumnHeader>
@ -104,6 +269,9 @@ export const NotificationRequests = ({ multiColumn }) => {
id={request.get('id')}
accountId={request.get('account')}
notificationsCount={request.get('notifications_count')}
showCheckbox={selectionMode}
checked={checkedRequestIds.includes(request.get('id'))}
toggleCheck={handleCheck}
/>
))}
</ScrollableList>

View file

@ -2,26 +2,33 @@ import { FormattedMessage } from 'react-intl';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import ReplyIcon from '@/material-icons/400-24px/reply-fill.svg?react';
import type { StatusVisibility } from 'mastodon/api_types/statuses';
import { me } from 'mastodon/initial_state';
import type { NotificationGroupMention } from 'mastodon/models/notification_group';
import type { Status } from 'mastodon/models/status';
import { useAppSelector } from 'mastodon/store';
import type { LabelRenderer } from './notification_group_with_status';
import { NotificationWithStatus } from './notification_with_status';
const labelRenderer: LabelRenderer = (values) => (
const mentionLabelRenderer: LabelRenderer = () => (
<FormattedMessage id='notification.label.mention' defaultMessage='Mention' />
);
const privateMentionLabelRenderer: LabelRenderer = () => (
<FormattedMessage
id='notification.mention'
defaultMessage='{name} mentioned you'
values={values}
id='notification.label.private_mention'
defaultMessage='Private mention'
/>
);
const privateMentionLabelRenderer: LabelRenderer = (values) => (
const replyLabelRenderer: LabelRenderer = () => (
<FormattedMessage id='notification.label.reply' defaultMessage='Reply' />
);
const privateReplyLabelRenderer: LabelRenderer = () => (
<FormattedMessage
id='notification.private_mention'
defaultMessage='{name} privately mentioned you'
values={values}
id='notification.label.private_reply'
defaultMessage='Private reply'
/>
);
@ -29,27 +36,30 @@ export const NotificationMention: React.FC<{
notification: NotificationGroupMention;
unread: boolean;
}> = ({ notification, unread }) => {
const statusVisibility = useAppSelector(
(state) =>
state.statuses.getIn([
notification.statusId,
'visibility',
]) as StatusVisibility,
);
const [isDirect, isReply] = useAppSelector((state) => {
const status = state.statuses.get(notification.statusId) as Status;
return [
status.get('visibility') === 'direct',
status.get('in_reply_to_account_id') === me,
] as const;
});
let labelRenderer = mentionLabelRenderer;
if (isReply && isDirect) labelRenderer = privateReplyLabelRenderer;
else if (isReply) labelRenderer = replyLabelRenderer;
else if (isDirect) labelRenderer = privateMentionLabelRenderer;
return (
<NotificationWithStatus
type='mention'
icon={statusVisibility === 'direct' ? AlternateEmailIcon : ReplyIcon}
icon={isReply ? ReplyIcon : AlternateEmailIcon}
iconId='reply'
accountIds={notification.sampleAccountIds}
count={notification.notifications_count}
statusId={notification.statusId}
labelRenderer={
statusVisibility === 'direct'
? privateMentionLabelRenderer
: labelRenderer
}
labelRenderer={labelRenderer}
unread={unread}
/>
);

View file

@ -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';
@ -26,16 +24,14 @@ import type { NotificationGap } from 'mastodon/reducers/notification_groups';
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';
@ -61,41 +57,19 @@ 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';
const lastReadId = useAppSelector((s) =>
selectSettingsNotificationsShowUnread(s)
? s.notificationGroups.lastReadId
? s.notificationGroups.readMarkerId
: '0',
);
@ -105,11 +79,13 @@ export const Notifications: React.FC<{
selectUnreadNotificationGroupsCount,
);
const anyPendingNotification = useAppSelector(selectAnyPendingNotification);
const isUnread = unreadNotificationsCount > 0;
const canMarkAsRead =
useAppSelector(selectSettingsNotificationsShowUnread) &&
unreadNotificationsCount > 0;
anyPendingNotification;
const needsNotificationPermission = useAppSelector(
selectNeedsNotificationPermission,

View file

@ -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';
@ -670,7 +671,7 @@ class Status extends ImmutablePureComponent {
};
render () {
let ancestors, descendants, references;
let ancestors, descendants, references, remoteHint;
const { isLoading, status, ancestorsIds, descendantsIds, referenceIds, intl, domain, multiColumn, pictureInPicture } = this.props;
const { fullscreen } = this.state;
@ -703,6 +704,10 @@ class Status extends ImmutablePureComponent {
const isLocal = status.getIn(['account', 'acct'], '').indexOf('@') === -1;
const isIndexable = !status.getIn(['account', 'noindex']);
if (!isLocal) {
remoteHint = <TimelineHint url={status.get('url')} resource={<FormattedMessage id='timeline_hint.resources.replies' defaultMessage='Some replies' />} />;
}
const handlers = {
moveUp: this.handleHotkeyMoveUp,
moveDown: this.handleHotkeyMoveDown,
@ -779,6 +784,7 @@ class Status extends ImmutablePureComponent {
</HotKeys>
{descendants}
{remoteHint}
</div>
</ScrollContainer>

View file

@ -0,0 +1,108 @@
import PropTypes from 'prop-types';
import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import { useDispatch } from 'react-redux';
import InventoryIcon from '@/material-icons/400-24px/inventory_2.svg?react';
import PersonAlertIcon from '@/material-icons/400-24px/person_alert.svg?react';
import ShieldQuestionIcon from '@/material-icons/400-24px/shield_question.svg?react';
import { closeModal } from 'mastodon/actions/modal';
import { updateNotificationsPolicy } from 'mastodon/actions/notification_policies';
import { Button } from 'mastodon/components/button';
import { Icon } from 'mastodon/components/icon';
export const IgnoreNotificationsModal = ({ filterType }) => {
const dispatch = useDispatch();
const handleClick = useCallback(() => {
dispatch(closeModal({ modalType: undefined, ignoreFocus: false }));
void dispatch(updateNotificationsPolicy({ [filterType]: 'drop' }));
}, [dispatch, filterType]);
const handleSecondaryClick = useCallback(() => {
dispatch(closeModal({ modalType: undefined, ignoreFocus: false }));
void dispatch(updateNotificationsPolicy({ [filterType]: 'filter' }));
}, [dispatch, filterType]);
const handleCancel = useCallback(() => {
dispatch(closeModal({ modalType: undefined, ignoreFocus: false }));
}, [dispatch]);
let title = null;
switch(filterType) {
case 'for_not_following':
title = <FormattedMessage id='ignore_notifications_modal.not_following_title' defaultMessage="Ignore notifications from people you don't follow?" />;
break;
case 'for_not_followers':
title = <FormattedMessage id='ignore_notifications_modal.not_followers_title' defaultMessage='Ignore notifications from people not following you?' />;
break;
case 'for_new_accounts':
title = <FormattedMessage id='ignore_notifications_modal.new_accounts_title' defaultMessage='Ignore notifications from new accounts?' />;
break;
case 'for_private_mentions':
title = <FormattedMessage id='ignore_notifications_modal.private_mentions_title' defaultMessage='Ignore notifications from unsolicited Private Mentions?' />;
break;
case 'for_limited_accounts':
title = <FormattedMessage id='ignore_notifications_modal.limited_accounts_title' defaultMessage='Ignore notifications from moderated accounts?' />;
break;
}
return (
<div className='modal-root__modal safety-action-modal'>
<div className='safety-action-modal__top'>
<div className='safety-action-modal__header'>
<h1>{title}</h1>
</div>
<div className='safety-action-modal__bullet-points'>
<div>
<div className='safety-action-modal__bullet-points__icon'><Icon icon={InventoryIcon} /></div>
<div><FormattedMessage id='ignore_notifications_modal.filter_to_review_separately' defaultMessage='You can review filtered notifications separately' /></div>
</div>
<div>
<div className='safety-action-modal__bullet-points__icon'><Icon icon={PersonAlertIcon} /></div>
<div><FormattedMessage id='ignore_notifications_modal.filter_to_act_users' defaultMessage="You'll still be able to accept, reject, or report users" /></div>
</div>
<div>
<div className='safety-action-modal__bullet-points__icon'><Icon icon={ShieldQuestionIcon} /></div>
<div><FormattedMessage id='ignore_notifications_modal.filter_to_avoid_confusion' defaultMessage='Filtering helps avoid potential confusion' /></div>
</div>
</div>
<div>
<FormattedMessage id='ignore_notifications_modal.disclaimer' defaultMessage="Mastodon cannot inform users that you've ignored their notifications. Ignoring notifications will not stop the messages themselves from being sent." />
</div>
</div>
<div className='safety-action-modal__bottom'>
<div className='safety-action-modal__actions'>
<Button onClick={handleSecondaryClick} secondary>
<FormattedMessage id='ignore_notifications_modal.filter_instead' defaultMessage='Filter instead' />
</Button>
<div className='spacer' />
<button onClick={handleCancel} className='link-button'>
<FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />
</button>
<button onClick={handleClick} className='link-button'>
<FormattedMessage id='ignore_notifications_modal.ignore' defaultMessage='Ignore notifications' />
</button>
</div>
</div>
</div>
);
};
IgnoreNotificationsModal.propTypes = {
filterType: PropTypes.string.isRequired,
};
export default IgnoreNotificationsModal;

View file

@ -22,6 +22,7 @@ import {
InteractionModal,
SubscribedLanguagesModal,
ClosedRegistrationsModal,
IgnoreNotificationsModal,
} from 'mastodon/features/ui/util/async-components';
import { getScrollbarWidth } from 'mastodon/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 {

View file

@ -178,6 +178,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');
}