Add notification policies and notification requests in web UI (#29433)

main
Eugen Rochko 2024-03-11 16:02:21 +01:00 committed by GitHub
parent 19efa1b9f1
commit c10bbf5fe3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 1278 additions and 252 deletions

View File

@ -18,6 +18,10 @@ class Api::V1::Notifications::RequestsController < Api::BaseController
render json: @requests, each_serializer: REST::NotificationRequestSerializer, relationships: @relationships
end
def show
render json: @request, serializer: REST::NotificationRequestSerializer
end
def accept
AcceptNotificationRequestService.new.call(@request)
render_empty
@ -31,7 +35,7 @@ class Api::V1::Notifications::RequestsController < Api::BaseController
private
def load_requests
requests = NotificationRequest.where(account: current_account).where(dismissed: truthy_param?(:dismissed)).includes(:last_status, from_account: [:account_stat, :user]).to_a_paginated_by_id(
requests = NotificationRequest.where(account: current_account).where(dismissed: truthy_param?(:dismissed) || false).includes(:last_status, from_account: [:account_stat, :user]).to_a_paginated_by_id(
limit_param(DEFAULT_ACCOUNTS_LIMIT),
params_slice(:max_id, :since_id, :min_id)
)

View File

@ -44,6 +44,38 @@ export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ';
export const NOTIFICATIONS_SET_BROWSER_SUPPORT = 'NOTIFICATIONS_SET_BROWSER_SUPPORT';
export const NOTIFICATIONS_SET_BROWSER_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION';
export const NOTIFICATION_POLICY_FETCH_REQUEST = 'NOTIFICATION_POLICY_FETCH_REQUEST';
export const NOTIFICATION_POLICY_FETCH_SUCCESS = 'NOTIFICATION_POLICY_FETCH_SUCCESS';
export const NOTIFICATION_POLICY_FETCH_FAIL = 'NOTIFICATION_POLICY_FETCH_FAIL';
export const NOTIFICATION_REQUESTS_FETCH_REQUEST = 'NOTIFICATION_REQUESTS_FETCH_REQUEST';
export const NOTIFICATION_REQUESTS_FETCH_SUCCESS = 'NOTIFICATION_REQUESTS_FETCH_SUCCESS';
export const NOTIFICATION_REQUESTS_FETCH_FAIL = 'NOTIFICATION_REQUESTS_FETCH_FAIL';
export const NOTIFICATION_REQUESTS_EXPAND_REQUEST = 'NOTIFICATION_REQUESTS_EXPAND_REQUEST';
export const NOTIFICATION_REQUESTS_EXPAND_SUCCESS = 'NOTIFICATION_REQUESTS_EXPAND_SUCCESS';
export const NOTIFICATION_REQUESTS_EXPAND_FAIL = 'NOTIFICATION_REQUESTS_EXPAND_FAIL';
export const NOTIFICATION_REQUEST_FETCH_REQUEST = 'NOTIFICATION_REQUEST_FETCH_REQUEST';
export const NOTIFICATION_REQUEST_FETCH_SUCCESS = 'NOTIFICATION_REQUEST_FETCH_SUCCESS';
export const NOTIFICATION_REQUEST_FETCH_FAIL = 'NOTIFICATION_REQUEST_FETCH_FAIL';
export const NOTIFICATION_REQUEST_ACCEPT_REQUEST = 'NOTIFICATION_REQUEST_ACCEPT_REQUEST';
export const NOTIFICATION_REQUEST_ACCEPT_SUCCESS = 'NOTIFICATION_REQUEST_ACCEPT_SUCCESS';
export const NOTIFICATION_REQUEST_ACCEPT_FAIL = 'NOTIFICATION_REQUEST_ACCEPT_FAIL';
export const NOTIFICATION_REQUEST_DISMISS_REQUEST = 'NOTIFICATION_REQUEST_DISMISS_REQUEST';
export const NOTIFICATION_REQUEST_DISMISS_SUCCESS = 'NOTIFICATION_REQUEST_DISMISS_SUCCESS';
export const NOTIFICATION_REQUEST_DISMISS_FAIL = 'NOTIFICATION_REQUEST_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';
export const NOTIFICATIONS_FOR_REQUEST_EXPAND_REQUEST = 'NOTIFICATIONS_FOR_REQUEST_EXPAND_REQUEST';
export const NOTIFICATIONS_FOR_REQUEST_EXPAND_SUCCESS = 'NOTIFICATIONS_FOR_REQUEST_EXPAND_SUCCESS';
export const NOTIFICATIONS_FOR_REQUEST_EXPAND_FAIL = 'NOTIFICATIONS_FOR_REQUEST_EXPAND_FAIL';
defineMessages({
mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
group: { id: 'notifications.group', defaultMessage: '{count} notifications' },
@ -313,3 +345,264 @@ export function setBrowserPermission (value) {
value,
};
}
export const fetchNotificationPolicy = () => (dispatch, getState) => {
dispatch(fetchNotificationPolicyRequest());
api(getState).get('/api/v1/notifications/policy').then(({ data }) => {
dispatch(fetchNotificationPolicySuccess(data));
}).catch(err => {
dispatch(fetchNotificationPolicyFail(err));
});
};
export const fetchNotificationPolicyRequest = () => ({
type: NOTIFICATION_POLICY_FETCH_REQUEST,
});
export const fetchNotificationPolicySuccess = policy => ({
type: NOTIFICATION_POLICY_FETCH_SUCCESS,
policy,
});
export const fetchNotificationPolicyFail = error => ({
type: NOTIFICATION_POLICY_FETCH_FAIL,
error,
});
export const updateNotificationsPolicy = params => (dispatch, getState) => {
dispatch(fetchNotificationPolicyRequest());
api(getState).put('/api/v1/notifications/policy', params).then(({ data }) => {
dispatch(fetchNotificationPolicySuccess(data));
}).catch(err => {
dispatch(fetchNotificationPolicyFail(err));
});
};
export const fetchNotificationRequests = () => (dispatch, getState) => {
const params = {};
if (getState().getIn(['notificationRequests', 'isLoading'])) {
return;
}
if (getState().getIn(['notificationRequests', 'items'])?.size > 0) {
params.since_id = getState().getIn(['notificationRequests', 'items', 0, 'id']);
}
dispatch(fetchNotificationRequestsRequest());
api(getState).get('/api/v1/notifications/requests', { params }).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data.map(x => x.account)));
dispatch(fetchNotificationRequestsSuccess(response.data, next ? next.uri : null));
}).catch(err => {
dispatch(fetchNotificationRequestsFail(err));
});
};
export const fetchNotificationRequestsRequest = () => ({
type: NOTIFICATION_REQUESTS_FETCH_REQUEST,
});
export const fetchNotificationRequestsSuccess = (requests, next) => ({
type: NOTIFICATION_REQUESTS_FETCH_SUCCESS,
requests,
next,
});
export const fetchNotificationRequestsFail = error => ({
type: NOTIFICATION_REQUESTS_FETCH_FAIL,
error,
});
export const expandNotificationRequests = () => (dispatch, getState) => {
const url = getState().getIn(['notificationRequests', 'next']);
if (!url || getState().getIn(['notificationRequests', 'isLoading'])) {
return;
}
dispatch(expandNotificationRequestsRequest());
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data.map(x => x.account)));
dispatch(expandNotificationRequestsSuccess(response.data, next?.uri));
}).catch(err => {
dispatch(expandNotificationRequestsFail(err));
});
};
export const expandNotificationRequestsRequest = () => ({
type: NOTIFICATION_REQUESTS_EXPAND_REQUEST,
});
export const expandNotificationRequestsSuccess = (requests, next) => ({
type: NOTIFICATION_REQUESTS_EXPAND_SUCCESS,
requests,
next,
});
export const expandNotificationRequestsFail = error => ({
type: NOTIFICATION_REQUESTS_EXPAND_FAIL,
error,
});
export const fetchNotificationRequest = id => (dispatch, getState) => {
const current = getState().getIn(['notificationRequests', 'current']);
if (current.getIn(['item', 'id']) === id || current.get('isLoading')) {
return;
}
dispatch(fetchNotificationRequestRequest(id));
api(getState).get(`/api/v1/notifications/requests/${id}`).then(({ data }) => {
dispatch(fetchNotificationRequestSuccess(data));
}).catch(err => {
dispatch(fetchNotificationRequestFail(id, err));
});
};
export const fetchNotificationRequestRequest = id => ({
type: NOTIFICATION_REQUEST_FETCH_REQUEST,
id,
});
export const fetchNotificationRequestSuccess = request => ({
type: NOTIFICATION_REQUEST_FETCH_SUCCESS,
request,
});
export const fetchNotificationRequestFail = (id, error) => ({
type: NOTIFICATION_REQUEST_FETCH_FAIL,
id,
error,
});
export const acceptNotificationRequest = id => (dispatch, getState) => {
dispatch(acceptNotificationRequestRequest(id));
api(getState).post(`/api/v1/notifications/requests/${id}/accept`).then(() => {
dispatch(acceptNotificationRequestSuccess(id));
}).catch(err => {
dispatch(acceptNotificationRequestFail(id, err));
});
};
export const acceptNotificationRequestRequest = id => ({
type: NOTIFICATION_REQUEST_ACCEPT_REQUEST,
id,
});
export const acceptNotificationRequestSuccess = id => ({
type: NOTIFICATION_REQUEST_ACCEPT_SUCCESS,
id,
});
export const acceptNotificationRequestFail = (id, error) => ({
type: NOTIFICATION_REQUEST_ACCEPT_FAIL,
id,
error,
});
export const dismissNotificationRequest = id => (dispatch, getState) => {
dispatch(dismissNotificationRequestRequest(id));
api(getState).post(`/api/v1/notifications/requests/${id}/dismiss`).then(() =>{
dispatch(dismissNotificationRequestSuccess(id));
}).catch(err => {
dispatch(dismissNotificationRequestFail(id, err));
});
};
export const dismissNotificationRequestRequest = id => ({
type: NOTIFICATION_REQUEST_DISMISS_REQUEST,
id,
});
export const dismissNotificationRequestSuccess = id => ({
type: NOTIFICATION_REQUEST_DISMISS_SUCCESS,
id,
});
export const dismissNotificationRequestFail = (id, error) => ({
type: NOTIFICATION_REQUEST_DISMISS_FAIL,
id,
error,
});
export const fetchNotificationsForRequest = accountId => (dispatch, getState) => {
const current = getState().getIn(['notificationRequests', 'current']);
const params = { account_id: accountId };
if (current.getIn(['item', 'account']) === accountId) {
if (current.getIn(['notifications', 'isLoading'])) {
return;
}
if (current.getIn(['notifications', 'items'])?.size > 0) {
params.since_id = current.getIn(['notifications', 'items', 0, 'id']);
}
}
dispatch(fetchNotificationsForRequestRequest());
api(getState).get('/api/v1/notifications', { params }).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status)));
dispatch(fetchNotificationsForRequestSuccess(response.data, next?.uri));
}).catch(err => {
dispatch(fetchNotificationsForRequestFail(err));
});
};
export const fetchNotificationsForRequestRequest = () => ({
type: NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST,
});
export const fetchNotificationsForRequestSuccess = (notifications, next) => ({
type: NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS,
notifications,
next,
});
export const fetchNotificationsForRequestFail = (error) => ({
type: NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL,
error,
});
export const expandNotificationsForRequest = () => (dispatch, getState) => {
const url = getState().getIn(['notificationRequests', 'current', 'notifications', 'next']);
if (!url || getState().getIn(['notificationRequests', 'current', 'notifications', 'isLoading'])) {
return;
}
dispatch(expandNotificationsForRequestRequest());
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status)));
dispatch(expandNotificationsForRequestSuccess(response.data, next?.uri));
}).catch(err => {
dispatch(expandNotificationsForRequestFail(err));
});
};
export const expandNotificationsForRequestRequest = () => ({
type: NOTIFICATIONS_FOR_REQUEST_EXPAND_REQUEST,
});
export const expandNotificationsForRequestSuccess = (notifications, next) => ({
type: NOTIFICATIONS_FOR_REQUEST_EXPAND_SUCCESS,
notifications,
next,
});
export const expandNotificationsForRequestFail = (error) => ({
type: NOTIFICATIONS_FOR_REQUEST_EXPAND_FAIL,
error,
});

View File

@ -1,7 +1,7 @@
import PropTypes from 'prop-types';
import { PureComponent, useCallback } from 'react';
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
import { FormattedMessage, injectIntl, defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames';
import { withRouter } from 'react-router-dom';
@ -11,7 +11,7 @@ import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react';
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import TuneIcon from '@/material-icons/400-24px/tune.svg?react';
import SettingsIcon from '@/material-icons/400-24px/settings.svg?react';
import { Icon } from 'mastodon/components/icon';
import { ButtonInTabsBar, useColumnsContext } from 'mastodon/features/ui/util/columns_context';
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
@ -23,10 +23,12 @@ const messages = defineMessages({
hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' },
moveLeft: { id: 'column_header.moveLeft_settings', defaultMessage: 'Move column to the left' },
moveRight: { id: 'column_header.moveRight_settings', defaultMessage: 'Move column to the right' },
back: { id: 'column_back_button.label', defaultMessage: 'Back' },
});
const BackButton = ({ pinned, show }) => {
const BackButton = ({ pinned, show, onlyIcon }) => {
const history = useAppHistory();
const intl = useIntl();
const { multiColumn } = useColumnsContext();
const handleBackClick = useCallback(() => {
@ -39,18 +41,20 @@ const BackButton = ({ pinned, show }) => {
const showButton = history && !pinned && ((multiColumn && history.location?.state?.fromMastodon) || show);
if(!showButton) return null;
if (!showButton) return null;
return (<button onClick={handleBackClick} className='column-header__back-button'>
return (
<button onClick={handleBackClick} className={classNames('column-header__back-button', { 'compact': onlyIcon })} aria-label={intl.formatMessage(messages.back)}>
<Icon id='chevron-left' icon={ArrowBackIcon} className='column-back-button__icon' />
<FormattedMessage id='column_back_button.label' defaultMessage='Back' />
</button>);
{!onlyIcon && <FormattedMessage id='column_back_button.label' defaultMessage='Back' />}
</button>
);
};
BackButton.propTypes = {
pinned: PropTypes.bool,
show: PropTypes.bool,
onlyIcon: PropTypes.bool,
};
class ColumnHeader extends PureComponent {
@ -145,27 +149,31 @@ class ColumnHeader extends PureComponent {
}
if (multiColumn && pinned) {
pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='times' icon={CloseIcon} /> <FormattedMessage id='column_header.unpin' defaultMessage='Unpin' /></button>;
pinButton = <button className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='times' icon={CloseIcon} /> <FormattedMessage id='column_header.unpin' defaultMessage='Unpin' /></button>;
moveButtons = (
<div key='move-buttons' className='column-header__setting-arrows'>
<div className='column-header__setting-arrows'>
<button title={formatMessage(messages.moveLeft)} aria-label={formatMessage(messages.moveLeft)} className='icon-button column-header__setting-btn' onClick={this.handleMoveLeft}><Icon id='chevron-left' icon={ChevronLeftIcon} /></button>
<button title={formatMessage(messages.moveRight)} aria-label={formatMessage(messages.moveRight)} className='icon-button column-header__setting-btn' onClick={this.handleMoveRight}><Icon id='chevron-right' icon={ChevronRightIcon} /></button>
</div>
);
} else if (multiColumn && this.props.onPin) {
pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='plus' icon={AddIcon} /> <FormattedMessage id='column_header.pin' defaultMessage='Pin' /></button>;
pinButton = <button className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='plus' icon={AddIcon} /> <FormattedMessage id='column_header.pin' defaultMessage='Pin' /></button>;
}
backButton = <BackButton pinned={pinned} show={showBackButton} />;
backButton = <BackButton pinned={pinned} show={showBackButton} onlyIcon={!!title} />;
const collapsedContent = [
extraContent,
];
if (multiColumn) {
collapsedContent.push(pinButton);
collapsedContent.push(moveButtons);
collapsedContent.push(
<div key='buttons' className='column-header__advanced-buttons'>
{pinButton}
{moveButtons}
</div>
);
}
if (this.context.identity.signedIn && (children || (multiColumn && this.props.onPin))) {
@ -177,7 +185,7 @@ class ColumnHeader extends PureComponent {
onClick={this.handleToggleClick}
>
<i className='icon-with-badge'>
<Icon id='sliders' icon={TuneIcon} />
<Icon id='sliders' icon={SettingsIcon} />
{collapseIssues && <i className='icon-with-badge__issue-badge' />}
</i>
</button>
@ -190,16 +198,19 @@ class ColumnHeader extends PureComponent {
<div className={wrapperClassName}>
<h1 className={buttonClassName}>
{hasTitle && (
<button onClick={this.handleTitleClick}>
<Icon id={icon} icon={iconComponent} className='column-header__icon' />
<>
{backButton}
<button onClick={this.handleTitleClick} className='column-header__title'>
{!showBackButton && <Icon id={icon} icon={iconComponent} className='column-header__icon' />}
{title}
</button>
</>
)}
{!hasTitle && backButton}
<div className='column-header__buttons'>
{hasTitle && backButton}
{extraButton}
{collapseButton}
</div>

View File

@ -20,7 +20,7 @@ class ColumnSettings extends PureComponent {
const { settings, onChange } = this.props;
return (
<div>
<div className='column-settings'>
<div className='column-settings__row'>
<SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media only' />} />
</div>

View File

@ -42,7 +42,8 @@ const ColumnSettings = () => {
);
return (
<div>
<div className='column-settings'>
<section>
<div className='column-settings__row'>
<SettingToggle
settings={settings}
@ -51,6 +52,7 @@ const ColumnSettings = () => {
label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media only' />}
/>
</div>
</section>
</div>
);
};

View File

@ -107,8 +107,11 @@ class ColumnSettings extends PureComponent {
const { settings, onChange } = this.props;
return (
<div>
<div className='column-settings'>
<section>
<div className='column-settings__row'>
<SettingToggle settings={settings} settingPath={['local']} onChange={onChange} label={<FormattedMessage id='community.column_settings.local_only' defaultMessage='Local only' />} />
<div className='setting-toggle'>
<Toggle id='hashtag.column_settings.tag_toggle' onChange={this.onToggle} checked={this.state.open} />
@ -125,10 +128,7 @@ class ColumnSettings extends PureComponent {
{this.modeSelect('none')}
</div>
)}
<div className='column-settings__row'>
<SettingToggle settings={settings} settingPath={['local']} onChange={onChange} label={<FormattedMessage id='community.column_settings.local_only' defaultMessage='Local only' />} />
</div>
</section>
</div>
);
}

View File

@ -24,14 +24,8 @@ export const ColumnSettings: React.FC = () => {
);
return (
<div>
<span className='column-settings__section'>
<FormattedMessage
id='home.column_settings.basic'
defaultMessage='Basic'
/>
</span>
<div className='column-settings'>
<section>
<div className='column-settings__row'>
<SettingToggle
prefix='home_timeline'
@ -45,9 +39,7 @@ export const ColumnSettings: React.FC = () => {
/>
}
/>
</div>
<div className='column-settings__row'>
<SettingToggle
prefix='home_timeline'
settings={settings}
@ -61,6 +53,7 @@ export const ColumnSettings: React.FC = () => {
}
/>
</div>
</section>
</div>
);
};

View File

@ -193,7 +193,8 @@ class ListTimeline extends PureComponent {
pinned={pinned}
multiColumn={multiColumn}
>
<div className='column-settings__row column-header__links'>
<div className='column-settings'>
<section className='column-header__links'>
<button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleEditClick}>
<Icon id='pencil' icon={EditIcon} /> <FormattedMessage id='lists.edit' defaultMessage='Edit list' />
</button>
@ -201,27 +202,29 @@ class ListTimeline extends PureComponent {
<button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleDeleteClick}>
<Icon id='trash' icon={DeleteIcon} /> <FormattedMessage id='lists.delete' defaultMessage='Delete list' />
</button>
</div>
</section>
<section>
<div className='setting-toggle'>
<Toggle id={`list-${id}-exclusive`} checked={isExclusive} onChange={this.onExclusiveToggle} />
<label htmlFor={`list-${id}-exclusive`} className='setting-toggle__label'>
<FormattedMessage id='lists.exclusive' defaultMessage='Hide these posts from home' />
</label>
</div>
</section>
{replies_policy !== undefined && (
<section aria-labelledby={`list-${id}-replies-policy`}>
<h3 id={`list-${id}-replies-policy`}><FormattedMessage id='lists.replies_policy.title' defaultMessage='Show replies to:' /></h3>
{ replies_policy !== undefined && (
<div role='group' aria-labelledby={`list-${id}-replies-policy`}>
<span id={`list-${id}-replies-policy`} className='column-settings__section'>
<FormattedMessage id='lists.replies_policy.title' defaultMessage='Show replies to:' />
</span>
<div className='column-settings__row'>
{ ['none', 'list', 'followed'].map(policy => (
<RadioButton name='order' key={policy} value={policy} label={intl.formatMessage(messages[policy])} checked={replies_policy === policy} onChange={this.handleRepliesPolicyChange} />
))}
</div>
</div>
</section>
)}
</div>
</ColumnHeader>
<StatusListContainer

View File

@ -0,0 +1,31 @@
import PropTypes from 'prop-types';
import { useCallback } from 'react';
import Toggle from 'react-toggle';
export const CheckboxWithLabel = ({ checked, disabled, children, onChange }) => {
const handleChange = useCallback(({ target }) => {
onChange(target.checked);
}, [onChange]);
return (
<label className='app-form__toggle'>
<div className='app-form__toggle__label'>
{children}
</div>
<div className='app-form__toggle__toggle'>
<div>
<Toggle checked={checked} onChange={handleChange} disabled={disabled} />
</div>
</div>
</label>
);
};
CheckboxWithLabel.propTypes = {
checked: PropTypes.bool,
disabled: PropTypes.bool,
children: PropTypes.children,
onChange: PropTypes.func,
};

View File

@ -7,6 +7,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_REPORTS } from 'mastodon/permissions';
import { CheckboxWithLabel } from './checkbox_with_label';
import ClearColumnButton from './clear_column_button';
import GrantPermissionButton from './grant_permission_button';
import SettingToggle from './setting_toggle';
@ -26,18 +27,34 @@ export default class ColumnSettings extends PureComponent {
alertsEnabled: PropTypes.bool,
browserSupport: PropTypes.bool,
browserPermission: PropTypes.string,
notificationPolicy: ImmutablePropTypes.map,
onChangePolicy: PropTypes.func.isRequired,
};
onPushChange = (path, checked) => {
this.props.onChange(['push', ...path], checked);
};
handleFilterNotFollowing = checked => {
this.props.onChangePolicy('filter_not_following', checked);
};
handleFilterNotFollowers = checked => {
this.props.onChangePolicy('filter_not_followers', checked);
};
handleFilterNewAccounts = checked => {
this.props.onChangePolicy('filter_new_accounts', checked);
};
handleFilterPrivateMentions = checked => {
this.props.onChangePolicy('filter_private_mentions', checked);
};
render () {
const { settings, pushSettings, onChange, onClear, alertsEnabled, browserSupport, browserPermission, onRequestNotificationPermission } = this.props;
const { settings, pushSettings, onChange, onClear, alertsEnabled, browserSupport, browserPermission, onRequestNotificationPermission, notificationPolicy } = this.props;
const unreadMarkersShowStr = <FormattedMessage id='notifications.column_settings.unread_notifications.highlight' defaultMessage='Highlight unread notifications' />;
const filterBarShowStr = <FormattedMessage id='notifications.column_settings.filter_bar.show_bar' defaultMessage='Show filter bar' />;
const filterAdvancedStr = <FormattedMessage id='notifications.column_settings.filter_bar.advanced' defaultMessage='Display all categories' />;
const alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />;
const showStr = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />;
const soundStr = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />;
@ -46,48 +63,59 @@ export default class ColumnSettings extends PureComponent {
const pushStr = showPushSettings && <FormattedMessage id='notifications.column_settings.push' defaultMessage='Push notifications' />;
return (
<div>
<div className='column-settings'>
{alertsEnabled && browserSupport && browserPermission === 'denied' && (
<div className='column-settings__row column-settings__row--with-margin'>
<span className='warning-hint'><FormattedMessage id='notifications.permission_denied' defaultMessage='Desktop notifications are unavailable due to previously denied browser permissions request' /></span>
</div>
)}
{alertsEnabled && browserSupport && browserPermission === 'default' && (
<div className='column-settings__row column-settings__row--with-margin'>
<span className='warning-hint'>
<FormattedMessage id='notifications.permission_required' defaultMessage='Desktop notifications are unavailable because the required permission has not been granted.' /> <GrantPermissionButton onClick={onRequestNotificationPermission} />
</span>
</div>
)}
<div className='column-settings__row'>
<section>
<ClearColumnButton onClick={onClear} />
</div>
</section>
<div role='group' aria-labelledby='notifications-unread-markers'>
<span id='notifications-unread-markers' className='column-settings__section'>
<section>
<h3><FormattedMessage id='notifications.policy.title' defaultMessage='Filter out notifications from…' /></h3>
<div className='column-settings__row'>
<CheckboxWithLabel checked={notificationPolicy.get('filter_not_following')} onChange={this.handleFilterNotFollowing}>
<strong><FormattedMessage id='notifications.policy.filter_not_following_title' defaultMessage="People you don't follow" /></strong>
<span className='hint'><FormattedMessage id='notifications.policy.filter_not_following_hint' defaultMessage='Until you manually approve them' /></span>
</CheckboxWithLabel>
<CheckboxWithLabel checked={notificationPolicy.get('filter_not_followers')} onChange={this.handleFilterNotFollowers}>
<strong><FormattedMessage id='notifications.policy.filter_not_followers_title' defaultMessage='People not following you' /></strong>
<span className='hint'><FormattedMessage id='notifications.policy.filter_not_followers_hint' defaultMessage='Including people who have been following you fewer than {days, plural, one {one day} other {# days}}' values={{ days: 3 }} /></span>
</CheckboxWithLabel>
<CheckboxWithLabel checked={notificationPolicy.get('filter_new_accounts')} onChange={this.handleFilterNewAccounts}>
<strong><FormattedMessage id='notifications.policy.filter_new_accounts_title' defaultMessage='New accounts' /></strong>
<span className='hint'><FormattedMessage id='notifications.policy.filter_new_accounts.hint' defaultMessage='Created within the past {days, plural, one {one day} other {# days}}' values={{ days: 30 }} /></span>
</CheckboxWithLabel>
<CheckboxWithLabel checked={notificationPolicy.get('filter_private_mentions')} onChange={this.handleFilterPrivateMentions}>
<strong><FormattedMessage id='notifications.policy.filter_private_mentions_title' defaultMessage='Unsolicited private mentions' /></strong>
<span className='hint'><FormattedMessage id='notifications.policy.filter_private_mentions_hint' defaultMessage="Filtered unless it's in reply to your own mention or if you follow the sender" /></span>
</CheckboxWithLabel>
</div>
</section>
<section role='group' aria-labelledby='notifications-unread-markers'>
<h3 id='notifications-unread-markers'>
<FormattedMessage id='notifications.column_settings.unread_notifications.category' defaultMessage='Unread notifications' />
</span>
</h3>
<div className='column-settings__row'>
<SettingToggle id='unread-notification-markers' prefix='notifications' settings={settings} settingPath={['showUnread']} onChange={onChange} label={unreadMarkersShowStr} />
</div>
</div>
</section>
<div role='group' aria-labelledby='notifications-filter-bar'>
<span id='notifications-filter-bar' className='column-settings__section'>
<FormattedMessage id='notifications.column_settings.filter_bar.category' defaultMessage='Quick filter bar' />
</span>
<div className='column-settings__row'>
<SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'show']} onChange={onChange} label={filterBarShowStr} />
<SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'advanced']} onChange={onChange} label={filterAdvancedStr} />
</div>
</div>
<div role='group' aria-labelledby='notifications-follow'>
<span id='notifications-follow' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span>
<section role='group' aria-labelledby='notifications-follow'>
<h3 id='notifications-follow'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></h3>
<div className='column-settings__row'>
<SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow']} onChange={onChange} label={alertStr} />
@ -95,10 +123,10 @@ export default class ColumnSettings extends PureComponent {
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'follow']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'follow']} onChange={onChange} label={soundStr} />
</div>
</div>
</section>
<div role='group' aria-labelledby='notifications-follow-request'>
<span id='notifications-follow-request' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow_request' defaultMessage='New follow requests:' /></span>
<section role='group' aria-labelledby='notifications-follow-request'>
<h3 id='notifications-follow-request'><FormattedMessage id='notifications.column_settings.follow_request' defaultMessage='New follow requests:' /></h3>
<div className='column-settings__row'>
<SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow_request']} onChange={onChange} label={alertStr} />
@ -106,10 +134,10 @@ export default class ColumnSettings extends PureComponent {
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'follow_request']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'follow_request']} onChange={onChange} label={soundStr} />
</div>
</div>
</section>
<div role='group' aria-labelledby='notifications-favourite'>
<span id='notifications-favourite' className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favorites:' /></span>
<section role='group' aria-labelledby='notifications-favourite'>
<h3 id='notifications-favourite'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favorites:' /></h3>
<div className='column-settings__row'>
<SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'favourite']} onChange={onChange} label={alertStr} />
@ -117,10 +145,10 @@ export default class ColumnSettings extends PureComponent {
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'favourite']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'favourite']} onChange={onChange} label={soundStr} />
</div>
</div>
</section>
<div role='group' aria-labelledby='notifications-mention'>
<span id='notifications-mention' className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span>
<section role='group' aria-labelledby='notifications-mention'>
<h3 id='notifications-mention'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></h3>
<div className='column-settings__row'>
<SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'mention']} onChange={onChange} label={alertStr} />
@ -128,10 +156,10 @@ export default class ColumnSettings extends PureComponent {
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'mention']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'mention']} onChange={onChange} label={soundStr} />
</div>
</div>
</section>
<div role='group' aria-labelledby='notifications-reblog'>
<span id='notifications-reblog' className='column-settings__section'><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span>
<section role='group' aria-labelledby='notifications-reblog'>
<h3 id='notifications-reblog'><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></h3>
<div className='column-settings__row'>
<SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reblog']} onChange={onChange} label={alertStr} />
@ -139,10 +167,10 @@ export default class ColumnSettings extends PureComponent {
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'reblog']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'reblog']} onChange={onChange} label={soundStr} />
</div>
</div>
</section>
<div role='group' aria-labelledby='notifications-poll'>
<span id='notifications-poll' className='column-settings__section'><FormattedMessage id='notifications.column_settings.poll' defaultMessage='Poll results:' /></span>
<section role='group' aria-labelledby='notifications-poll'>
<h3 id='notifications-poll'><FormattedMessage id='notifications.column_settings.poll' defaultMessage='Poll results:' /></h3>
<div className='column-settings__row'>
<SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'poll']} onChange={onChange} label={alertStr} />
@ -150,10 +178,10 @@ export default class ColumnSettings extends PureComponent {
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'poll']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'poll']} onChange={onChange} label={soundStr} />
</div>
</div>
</section>
<div role='group' aria-labelledby='notifications-status'>
<span id='notifications-status' className='column-settings__section'><FormattedMessage id='notifications.column_settings.status' defaultMessage='New posts:' /></span>
<section role='group' aria-labelledby='notifications-status'>
<h3 id='notifications-status'><FormattedMessage id='notifications.column_settings.status' defaultMessage='New posts:' /></h3>
<div className='column-settings__row'>
<SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'status']} onChange={onChange} label={alertStr} />
@ -161,10 +189,10 @@ export default class ColumnSettings extends PureComponent {
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'status']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'status']} onChange={onChange} label={soundStr} />
</div>
</div>
</section>
<div role='group' aria-labelledby='notifications-update'>
<span id='notifications-update' className='column-settings__section'><FormattedMessage id='notifications.column_settings.update' defaultMessage='Edits:' /></span>
<section role='group' aria-labelledby='notifications-update'>
<h3 id='notifications-update'><FormattedMessage id='notifications.column_settings.update' defaultMessage='Edits:' /></h3>
<div className='column-settings__row'>
<SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'update']} onChange={onChange} label={alertStr} />
@ -172,11 +200,11 @@ export default class ColumnSettings extends PureComponent {
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'update']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'update']} onChange={onChange} label={soundStr} />
</div>
</div>
</section>
{((this.context.identity.permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) && (
<div role='group' aria-labelledby='notifications-admin-sign-up'>
<span id='notifications-status' className='column-settings__section'><FormattedMessage id='notifications.column_settings.admin.sign_up' defaultMessage='New sign-ups:' /></span>
<section role='group' aria-labelledby='notifications-admin-sign-up'>
<h3 id='notifications-status'><FormattedMessage id='notifications.column_settings.admin.sign_up' defaultMessage='New sign-ups:' /></h3>
<div className='column-settings__row'>
<SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'admin.sign_up']} onChange={onChange} label={alertStr} />
@ -184,12 +212,12 @@ export default class ColumnSettings extends PureComponent {
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'admin.sign_up']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'admin.sign_up']} onChange={onChange} label={soundStr} />
</div>
</div>
</section>
)}
{((this.context.identity.permissions & PERMISSION_MANAGE_REPORTS) === PERMISSION_MANAGE_REPORTS) && (
<div role='group' aria-labelledby='notifications-admin-report'>
<span id='notifications-status' className='column-settings__section'><FormattedMessage id='notifications.column_settings.admin.report' defaultMessage='New reports:' /></span>
<section role='group' aria-labelledby='notifications-admin-report'>
<h3 id='notifications-status'><FormattedMessage id='notifications.column_settings.admin.report' defaultMessage='New reports:' /></h3>
<div className='column-settings__row'>
<SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'admin.report']} onChange={onChange} label={alertStr} />
@ -197,7 +225,7 @@ export default class ColumnSettings extends PureComponent {
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'admin.report']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'admin.report']} onChange={onChange} label={soundStr} />
</div>
</div>
</section>
)}
</div>
);

View File

@ -0,0 +1,49 @@
import { useEffect } from 'react';
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import ArchiveIcon from '@/material-icons/400-24px/archive.svg?react';
import { fetchNotificationPolicy } from 'mastodon/actions/notifications';
import { Icon } from 'mastodon/components/icon';
import { toCappedNumber } from 'mastodon/utils/numbers';
export const FilteredNotificationsBanner = () => {
const dispatch = useDispatch();
const policy = useSelector(state => state.get('notificationPolicy'));
useEffect(() => {
dispatch(fetchNotificationPolicy());
const interval = setInterval(() => {
dispatch(fetchNotificationPolicy());
}, 120000);
return () => {
clearInterval(interval);
};
}, [dispatch]);
if (policy === null || policy.getIn(['summary', 'pending_notifications_count']) * 1 === 0) {
return null;
}
return (
<Link className='filtered-notifications-banner' to='/notifications/requests'>
<Icon icon={ArchiveIcon} />
<div className='filtered-notifications-banner__text'>
<strong><FormattedMessage id='filtered_notifications_banner.title' defaultMessage='Filtered notifications' /></strong>
<span><FormattedMessage id='filtered_notifications_banner.pending_requests' defaultMessage='Notifications from {count, plural, =0 {no} one {one person} other {# people}} you may know' values={{ count: policy.getIn(['summary', 'pending_requests_count']) }} /></span>
</div>
<div className='filtered-notifications-banner__badge'>
{toCappedNumber(policy.getIn(['summary', 'pending_notifications_count']))}
</div>
</Link>
);
};

View File

@ -0,0 +1,65 @@
import PropTypes from 'prop-types';
import { useCallback } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import { useSelector, useDispatch } from 'react-redux';
import DoneIcon from '@/material-icons/400-24px/done.svg?react';
import VolumeOffIcon from '@/material-icons/400-24px/volume_off.svg?react';
import { acceptNotificationRequest, dismissNotificationRequest } from 'mastodon/actions/notifications';
import { Avatar } from 'mastodon/components/avatar';
import { IconButton } from 'mastodon/components/icon_button';
import { makeGetAccount } from 'mastodon/selectors';
import { toCappedNumber } from 'mastodon/utils/numbers';
const getAccount = makeGetAccount();
const messages = defineMessages({
accept: { id: 'notification_requests.accept', defaultMessage: 'Accept' },
dismiss: { id: 'notification_requests.dismiss', defaultMessage: 'Dismiss' },
});
export const NotificationRequest = ({ id, accountId, notificationsCount }) => {
const dispatch = useDispatch();
const account = useSelector(state => getAccount(state, accountId));
const intl = useIntl();
const handleDismiss = useCallback(() => {
dispatch(dismissNotificationRequest(id));
}, [dispatch, id]);
const handleAccept = useCallback(() => {
dispatch(acceptNotificationRequest(id));
}, [dispatch, id]);
return (
<div className='notification-request'>
<Link to={`/notifications/requests/${id}`} className='notification-request__link'>
<Avatar account={account} size={36} />
<div className='notification-request__name'>
<div className='notification-request__name__display-name'>
<bdi><strong dangerouslySetInnerHTML={{ __html: account?.get('display_name_html') }} /></bdi>
<span className='filtered-notifications-banner__badge'>{toCappedNumber(notificationsCount)}</span>
</div>
<span>@{account?.get('acct')}</span>
</div>
</Link>
<div className='notification-request__actions'>
<IconButton iconComponent={VolumeOffIcon} onClick={handleDismiss} title={intl.formatMessage(messages.dismiss)} />
<IconButton iconComponent={DoneIcon} onClick={handleAccept} title={intl.formatMessage(messages.accept)} />
</div>
</div>
);
};
NotificationRequest.propTypes = {
id: PropTypes.string.isRequired,
accountId: PropTypes.string.isRequired,
notificationsCount: PropTypes.string.isRequired,
};

View File

@ -4,7 +4,7 @@ import { connect } from 'react-redux';
import { showAlert } from '../../../actions/alerts';
import { openModal } from '../../../actions/modal';
import { setFilter, clearNotifications, requestBrowserPermission } from '../../../actions/notifications';
import { setFilter, clearNotifications, requestBrowserPermission, updateNotificationsPolicy } from '../../../actions/notifications';
import { changeAlerts as changePushNotifications } from '../../../actions/push_notifications';
import { changeSetting } from '../../../actions/settings';
import ColumnSettings from '../components/column_settings';
@ -21,6 +21,7 @@ const mapStateToProps = state => ({
alertsEnabled: state.getIn(['settings', 'notifications', 'alerts']).includes(true),
browserSupport: state.getIn(['notifications', 'browserSupport']),
browserPermission: state.getIn(['notifications', 'browserPermission']),
notificationPolicy: state.get('notificationPolicy'),
});
const mapDispatchToProps = (dispatch, { intl }) => ({
@ -73,6 +74,12 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch(requestBrowserPermission());
},
onChangePolicy (param, checked) {
dispatch(updateNotificationsPolicy({
[param]: checked,
}));
},
});
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ColumnSettings));

View File

@ -5,7 +5,7 @@ import FilterBar from '../components/filter_bar';
const makeMapStateToProps = state => ({
selectedFilter: state.getIn(['settings', 'notifications', 'quickFilter', 'active']),
advancedMode: state.getIn(['settings', 'notifications', 'quickFilter', 'advanced']),
advancedMode: false,
});
const mapDispatchToProps = (dispatch) => ({

View File

@ -33,6 +33,7 @@ import ColumnHeader from '../../components/column_header';
import { LoadGap } from '../../components/load_gap';
import ScrollableList from '../../components/scrollable_list';
import { FilteredNotificationsBanner } from './components/filtered_notifications_banner';
import NotificationsPermissionBanner from './components/notifications_permission_banner';
import ColumnSettingsContainer from './containers/column_settings_container';
import FilterBarContainer from './containers/filter_bar_container';
@ -65,7 +66,6 @@ const getNotifications = createSelector([
});
const mapStateToProps = state => ({
showFilterBar: state.getIn(['settings', 'notifications', 'quickFilter', 'show']),
notifications: getNotifications(state),
isLoading: state.getIn(['notifications', 'isLoading'], 0) > 0,
isUnread: state.getIn(['notifications', 'unread']) > 0 || state.getIn(['notifications', 'pendingItems']).size > 0,
@ -85,7 +85,6 @@ class Notifications extends PureComponent {
static propTypes = {
columnId: PropTypes.string,
notifications: ImmutablePropTypes.list.isRequired,
showFilterBar: PropTypes.bool.isRequired,
dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
isLoading: PropTypes.bool,
@ -188,14 +187,14 @@ class Notifications extends PureComponent {
};
render () {
const { intl, notifications, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead, needsNotificationPermission } = this.props;
const { intl, notifications, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, lastReadId, canMarkAsRead, needsNotificationPermission } = this.props;
const pinned = !!columnId;
const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. When other people interact with you, you will see it here." />;
const { signedIn } = this.context.identity;
let scrollableContent = null;
const filterBarContainer = (signedIn && showFilterBar)
const filterBarContainer = signedIn
? (<FilterBarContainer />)
: null;
@ -285,6 +284,9 @@ class Notifications extends PureComponent {
</ColumnHeader>
{filterBarContainer}
<FilteredNotificationsBanner />
{scrollContainer}
<Helmet>

View File

@ -0,0 +1,144 @@
import PropTypes from 'prop-types';
import { useRef, useCallback, useEffect } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { Helmet } from 'react-helmet';
import { useSelector, useDispatch } from 'react-redux';
import ArchiveIcon from '@/material-icons/400-24px/archive.svg?react';
import DoneIcon from '@/material-icons/400-24px/done.svg?react';
import VolumeOffIcon from '@/material-icons/400-24px/volume_off.svg?react';
import { fetchNotificationRequest, fetchNotificationsForRequest, expandNotificationsForRequest, acceptNotificationRequest, dismissNotificationRequest } from 'mastodon/actions/notifications';
import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header';
import { IconButton } from 'mastodon/components/icon_button';
import ScrollableList from 'mastodon/components/scrollable_list';
import NotificationContainer from './containers/notification_container';
const messages = defineMessages({
title: { id: 'notification_requests.notifications_from', defaultMessage: 'Notifications from {name}' },
accept: { id: 'notification_requests.accept', defaultMessage: 'Accept' },
dismiss: { id: 'notification_requests.dismiss', defaultMessage: 'Dismiss' },
});
const selectChild = (ref, index, alignTop) => {
const container = ref.current.node;
const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
if (element) {
if (alignTop && container.scrollTop > element.offsetTop) {
element.scrollIntoView(true);
} else if (!alignTop && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
element.scrollIntoView(false);
}
element.focus();
}
};
export const NotificationRequest = ({ multiColumn, params: { id } }) => {
const columnRef = useRef();
const intl = useIntl();
const dispatch = useDispatch();
const notificationRequest = useSelector(state => state.getIn(['notificationRequests', 'current', 'item', 'id']) === id ? state.getIn(['notificationRequests', 'current', 'item']) : null);
const accountId = notificationRequest?.get('account');
const account = useSelector(state => state.getIn(['accounts', accountId]));
const notifications = useSelector(state => state.getIn(['notificationRequests', 'current', 'notifications', 'items']));
const isLoading = useSelector(state => state.getIn(['notificationRequests', 'current', 'notifications', 'isLoading']));
const hasMore = useSelector(state => !!state.getIn(['notificationRequests', 'current', 'notifications', 'next']));
const removed = useSelector(state => state.getIn(['notificationRequests', 'current', 'removed']));
const handleHeaderClick = useCallback(() => {
columnRef.current?.scrollTop();
}, [columnRef]);
const handleLoadMore = useCallback(() => {
dispatch(expandNotificationsForRequest());
}, [dispatch]);
const handleDismiss = useCallback(() => {
dispatch(dismissNotificationRequest(id));
}, [dispatch, id]);
const handleAccept = useCallback(() => {
dispatch(acceptNotificationRequest(id));
}, [dispatch, id]);
const handleMoveUp = useCallback(id => {
const elementIndex = notifications.findIndex(item => item !== null && item.get('id') === id) - 1;
selectChild(columnRef, elementIndex, true);
}, [columnRef, notifications]);
const handleMoveDown = useCallback(id => {
const elementIndex = notifications.findIndex(item => item !== null && item.get('id') === id) + 1;
selectChild(columnRef, elementIndex, false);
}, [columnRef, notifications]);
useEffect(() => {
dispatch(fetchNotificationRequest(id));
}, [dispatch, id]);
useEffect(() => {
if (accountId) {
dispatch(fetchNotificationsForRequest(accountId));
}
}, [dispatch, accountId]);
const columnTitle = intl.formatMessage(messages.title, { name: account?.get('display_name') });
return (
<Column bindToDocument={!multiColumn} ref={columnRef} label={columnTitle}>
<ColumnHeader
icon='archive'
iconComponent={ArchiveIcon}
title={columnTitle}
onClick={handleHeaderClick}
multiColumn={multiColumn}
showBackButton
extraButton={!removed && (
<>
<IconButton className='column-header__button' iconComponent={VolumeOffIcon} onClick={handleDismiss} title={intl.formatMessage(messages.dismiss)} />
<IconButton className='column-header__button' iconComponent={DoneIcon} onClick={handleAccept} title={intl.formatMessage(messages.accept)} />
</>
)}
/>
<ScrollableList
scrollKey={`notification_requests/${id}`}
trackScroll={!multiColumn}
bindToDocument={!multiColumn}
isLoading={isLoading}
showLoading={isLoading && notifications.size === 0}
hasMore={hasMore}
onLoadMore={handleLoadMore}
>
{notifications.map(item => (
item && <NotificationContainer
key={item.get('id')}
notification={item}
accountId={item.get('account')}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
/>
))}
</ScrollableList>
<Helmet>
<title>{columnTitle}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
NotificationRequest.propTypes = {
multiColumn: PropTypes.bool,
params: PropTypes.shape({
id: PropTypes.string.isRequired,
}),
};
export default NotificationRequest;

View File

@ -0,0 +1,85 @@
import PropTypes from 'prop-types';
import { useRef, useCallback, useEffect } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { useSelector, useDispatch } from 'react-redux';
import ArchiveIcon from '@/material-icons/400-24px/archive.svg?react';
import { fetchNotificationRequests, expandNotificationRequests } from 'mastodon/actions/notifications';
import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header';
import ScrollableList from 'mastodon/components/scrollable_list';
import { NotificationRequest } from './components/notification_request';
const messages = defineMessages({
title: { id: 'notification_requests.title', defaultMessage: 'Filtered notifications' },
});
export const NotificationRequests = ({ multiColumn }) => {
const columnRef = useRef();
const intl = useIntl();
const dispatch = useDispatch();
const isLoading = useSelector(state => state.getIn(['notificationRequests', 'isLoading']));
const notificationRequests = useSelector(state => state.getIn(['notificationRequests', 'items']));
const hasMore = useSelector(state => !!state.getIn(['notificationRequests', 'next']));
const handleHeaderClick = useCallback(() => {
columnRef.current?.scrollTop();
}, [columnRef]);
const handleLoadMore = useCallback(() => {
dispatch(expandNotificationRequests());
}, [dispatch]);
useEffect(() => {
dispatch(fetchNotificationRequests());
}, [dispatch]);
return (
<Column bindToDocument={!multiColumn} ref={columnRef} label={intl.formatMessage(messages.title)}>
<ColumnHeader
icon='archive'
iconComponent={ArchiveIcon}
title={intl.formatMessage(messages.title)}
onClick={handleHeaderClick}
multiColumn={multiColumn}
showBackButton
/>
<ScrollableList
scrollKey='notification_requests'
trackScroll={!multiColumn}
bindToDocument={!multiColumn}
isLoading={isLoading}
showLoading={isLoading && notificationRequests.size === 0}
hasMore={hasMore}
onLoadMore={handleLoadMore}
emptyMessage={<FormattedMessage id='empty_column.notification_requests' defaultMessage='All clear! There is nothing here. When you receive new notifications, they will appear here according to your settings.' />}
>
{notificationRequests.map(request => (
<NotificationRequest
key={request.get('id')}
id={request.get('id')}
accountId={request.get('account')}
notificationsCount={request.get('notifications_count')}
/>
))}
</ScrollableList>
<Helmet>
<title>{intl.formatMessage(messages.title)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
NotificationRequests.propTypes = {
multiColumn: PropTypes.bool,
};
export default NotificationRequests;

View File

@ -20,11 +20,13 @@ class ColumnSettings extends PureComponent {
const { settings, onChange } = this.props;
return (
<div>
<div className='column-settings'>
<section>
<div className='column-settings__row'>
<SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media only' />} />
<SettingToggle settings={settings} settingPath={['other', 'onlyRemote']} onChange={onChange} label={<FormattedMessage id='community.column_settings.remote_only' defaultMessage='Remote only' />} />
</div>
</section>
</div>
);
}

View File

@ -48,6 +48,8 @@ import {
DirectTimeline,
HashtagTimeline,
Notifications,
NotificationRequests,
NotificationRequest,
FollowRequests,
FavouritedStatuses,
BookmarkedStatuses,
@ -203,7 +205,9 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path={['/conversations', '/timelines/direct']} component={DirectTimeline} content={children} />
<WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} />
<WrappedRoute path='/lists/:id' component={ListTimeline} content={children} />
<WrappedRoute path='/notifications' component={Notifications} content={children} />
<WrappedRoute path='/notifications' component={Notifications} content={children} exact />
<WrappedRoute path='/notifications/requests' component={NotificationRequests} content={children} exact />
<WrappedRoute path='/notifications/requests/:id' component={NotificationRequest} content={children} exact />
<WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
<WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} />

View File

@ -189,3 +189,11 @@ export function About () {
export function PrivacyPolicy () {
return import(/*webpackChunkName: "features/privacy_policy" */'../../privacy_policy');
}
export function NotificationRequests () {
return import(/*webpackChunkName: "features/notifications/requests" */'../../notifications/requests');
}
export function NotificationRequest () {
return import(/*webpackChunkName: "features/notifications/request" */'../../notifications/request');
}

View File

@ -241,6 +241,7 @@
"empty_column.list": "There is nothing in this list yet. When members of this list publish new posts, they will appear here.",
"empty_column.lists": "You don't have any lists yet. When you create one, it will show up here.",
"empty_column.mutes": "You haven't muted any users yet.",
"empty_column.notification_requests": "All clear! There is nothing here. When you receive new notifications, they will appear here according to your settings.",
"empty_column.notifications": "You don't have any notifications yet. When other people interact with you, you will see it here.",
"empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other servers to fill it up",
"error.unexpected_crash.explanation": "Due to a bug in our code or a browser compatibility issue, this page could not be displayed correctly.",
@ -271,6 +272,8 @@
"filter_modal.select_filter.subtitle": "Use an existing category or create a new one",
"filter_modal.select_filter.title": "Filter this post",
"filter_modal.title.status": "Filter a post",
"filtered_notifications_banner.pending_requests": "Notifications from {count, plural, =0 {no} one {one person} other {# people}} you may know",
"filtered_notifications_banner.title": "Filtered notifications",
"firehose.all": "All",
"firehose.local": "This server",
"firehose.remote": "Other servers",
@ -314,7 +317,6 @@
"hashtag.follow": "Follow hashtag",
"hashtag.unfollow": "Unfollow hashtag",
"hashtags.and_other": "…and {count, plural, other {# more}}",
"home.column_settings.basic": "Basic",
"home.column_settings.show_reblogs": "Show boosts",
"home.column_settings.show_replies": "Show replies",
"home.hide_announcements": "Hide announcements",
@ -440,15 +442,16 @@
"notification.reblog": "{name} boosted your post",
"notification.status": "{name} just posted",
"notification.update": "{name} edited a post",
"notification_requests.accept": "Accept",
"notification_requests.dismiss": "Dismiss",
"notification_requests.notifications_from": "Notifications from {name}",
"notification_requests.title": "Filtered notifications",
"notifications.clear": "Clear notifications",
"notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?",
"notifications.column_settings.admin.report": "New reports:",
"notifications.column_settings.admin.sign_up": "New sign-ups:",
"notifications.column_settings.alert": "Desktop notifications",
"notifications.column_settings.favourite": "Favorites:",
"notifications.column_settings.filter_bar.advanced": "Display all categories",
"notifications.column_settings.filter_bar.category": "Quick filter bar",
"notifications.column_settings.filter_bar.show_bar": "Show filter bar",
"notifications.column_settings.follow": "New followers:",
"notifications.column_settings.follow_request": "New follow requests:",
"notifications.column_settings.mention": "Mentions:",
@ -474,6 +477,15 @@
"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.filter_new_accounts.hint": "Created within the past {days, plural, one {one day} other {# days}}",
"notifications.policy.filter_new_accounts_title": "New accounts",
"notifications.policy.filter_not_followers_hint": "Including people who have been following you fewer than {days, plural, one {one day} other {# days}}",
"notifications.policy.filter_not_followers_title": "People not following you",
"notifications.policy.filter_not_following_hint": "Until you manually approve them",
"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_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",

View File

@ -27,6 +27,8 @@ import media_attachments from './media_attachments';
import meta from './meta';
import { modalReducer } from './modal';
import mutes from './mutes';
import { notificationPolicyReducer } from './notification_policy';
import { notificationRequestsReducer } from './notification_requests';
import notifications from './notifications';
import picture_in_picture from './picture_in_picture';
import polls from './polls';
@ -84,6 +86,8 @@ const reducers = {
history,
tags,
followed_tags,
notificationPolicy: notificationPolicyReducer,
notificationRequests: notificationRequestsReducer,
};
// We want the root state to be an ImmutableRecord, which is an object with a defined list of keys,

View File

@ -0,0 +1,12 @@
import { fromJS } from 'immutable';
import { NOTIFICATION_POLICY_FETCH_SUCCESS } from 'mastodon/actions/notifications';
export const notificationPolicyReducer = (state = null, action) => {
switch(action.type) {
case NOTIFICATION_POLICY_FETCH_SUCCESS:
return fromJS(action.policy);
default:
return state;
}
};

View File

@ -0,0 +1,96 @@
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
import {
NOTIFICATION_REQUESTS_EXPAND_REQUEST,
NOTIFICATION_REQUESTS_EXPAND_SUCCESS,
NOTIFICATION_REQUESTS_EXPAND_FAIL,
NOTIFICATION_REQUESTS_FETCH_REQUEST,
NOTIFICATION_REQUESTS_FETCH_SUCCESS,
NOTIFICATION_REQUESTS_FETCH_FAIL,
NOTIFICATION_REQUEST_FETCH_REQUEST,
NOTIFICATION_REQUEST_FETCH_SUCCESS,
NOTIFICATION_REQUEST_FETCH_FAIL,
NOTIFICATION_REQUEST_ACCEPT_REQUEST,
NOTIFICATION_REQUEST_DISMISS_REQUEST,
NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST,
NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS,
NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL,
NOTIFICATIONS_FOR_REQUEST_EXPAND_REQUEST,
NOTIFICATIONS_FOR_REQUEST_EXPAND_SUCCESS,
NOTIFICATIONS_FOR_REQUEST_EXPAND_FAIL,
} from 'mastodon/actions/notifications';
import { notificationToMap } from './notifications';
const initialState = ImmutableMap({
items: ImmutableList(),
isLoading: false,
next: null,
current: ImmutableMap({
isLoading: false,
item: null,
removed: false,
notifications: ImmutableMap({
items: ImmutableList(),
isLoading: false,
next: null,
}),
}),
});
const normalizeRequest = request => fromJS({
...request,
account: request.account.id,
});
const removeRequest = (state, id) => {
if (state.getIn(['current', 'item', 'id']) === id) {
state = state.setIn(['current', 'removed'], true);
}
return state.update('items', list => list.filterNot(item => item.get('id') === id));
};
export const notificationRequestsReducer = (state = initialState, action) => {
switch(action.type) {
case NOTIFICATION_REQUESTS_FETCH_SUCCESS:
return state.withMutations(map => {
map.update('items', list => ImmutableList(action.requests.map(normalizeRequest)).concat(list));
map.set('isLoading', false);
map.update('next', next => next ?? action.next);
});
case NOTIFICATION_REQUESTS_EXPAND_SUCCESS:
return state.withMutations(map => {
map.update('items', list => list.concat(ImmutableList(action.requests.map(normalizeRequest))));
map.set('isLoading', false);
map.set('next', action.next);
});
case NOTIFICATION_REQUESTS_EXPAND_REQUEST:
case NOTIFICATION_REQUESTS_FETCH_REQUEST:
return state.set('isLoading', true);
case NOTIFICATION_REQUESTS_EXPAND_FAIL:
case NOTIFICATION_REQUESTS_FETCH_FAIL:
return state.set('isLoading', false);
case NOTIFICATION_REQUEST_ACCEPT_REQUEST:
case NOTIFICATION_REQUEST_DISMISS_REQUEST:
return removeRequest(state, action.id);
case NOTIFICATION_REQUEST_FETCH_REQUEST:
return state.set('current', initialState.get('current').set('isLoading', true));
case NOTIFICATION_REQUEST_FETCH_SUCCESS:
return state.update('current', map => map.set('isLoading', false).set('item', normalizeRequest(action.request)));
case NOTIFICATION_REQUEST_FETCH_FAIL:
return state.update('current', map => map.set('isLoading', false));
case NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST:
case NOTIFICATIONS_FOR_REQUEST_EXPAND_REQUEST:
return state.setIn(['current', 'notifications', 'isLoading'], true);
case NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS:
return state.updateIn(['current', 'notifications'], map => map.set('isLoading', false).update('items', list => ImmutableList(action.notifications.map(notificationToMap)).concat(list)).update('next', next => next ?? action.next));
case NOTIFICATIONS_FOR_REQUEST_EXPAND_SUCCESS:
return state.updateIn(['current', 'notifications'], map => map.set('isLoading', false).update('items', list => list.concat(ImmutableList(action.notifications.map(notificationToMap)))).set('next', action.next));
case NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL:
case NOTIFICATIONS_FOR_REQUEST_EXPAND_FAIL:
return state.setIn(['current', 'notifications', 'isLoading'], false);
default:
return state;
}
};

View File

@ -48,7 +48,7 @@ const initialState = ImmutableMap({
browserPermission: 'default',
});
const notificationToMap = notification => ImmutableMap({
export const notificationToMap = notification => ImmutableMap({
id: notification.id,
type: notification.type,
account: notification.account.id,

View File

@ -69,3 +69,11 @@ export function pluralReady(
export function roundTo10(num: number): number {
return Math.round(num * 0.1) / 0.1;
}
export function toCappedNumber(num: string): string {
if (parseInt(num) > 99) {
return '99+';
} else {
return num;
}
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m480-240 160-160-56-56-64 64v-168h-80v168l-64-64-56 56 160 160ZM200-120q-33 0-56.5-23.5T120-200v-499q0-14 4.5-27t13.5-24l50-61q11-14 27.5-21.5T250-840h460q18 0 34.5 7.5T772-811l50 61q9 11 13.5 24t4.5 27v499q0 33-23.5 56.5T760-120H200Zm16-600h528l-34-40H250l-34 40Z"/></svg>

After

Width:  |  Height:  |  Size: 370 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m480-240 160-160-56-56-64 64v-168h-80v168l-64-64-56 56 160 160ZM200-640v440h560v-440H200Zm0 520q-33 0-56.5-23.5T120-200v-499q0-14 4.5-27t13.5-24l50-61q11-14 27.5-21.5T250-840h460q18 0 34.5 7.5T772-811l50 61q9 11 13.5 24t4.5 27v499q0 33-23.5 56.5T760-120H200Zm16-600h528l-34-40H250l-34 40Zm264 300Z"/></svg>

After

Width:  |  Height:  |  Size: 403 B

View File

@ -3289,12 +3289,13 @@ $ui-header-height: 55px;
border: 0;
border-bottom: 1px solid lighten($ui-base-color, 8%);
text-align: unset;
padding: 15px;
padding: 13px;
margin: 0;
z-index: 3;
outline: 0;
display: flex;
align-items: center;
gap: 5px;
&:hover {
text-decoration: underline;
@ -3304,6 +3305,7 @@ $ui-header-height: 55px;
.column-header__back-button {
display: flex;
align-items: center;
gap: 5px;
background: $ui-base-color;
border: 0;
font-family: inherit;
@ -3311,23 +3313,19 @@ $ui-header-height: 55px;
cursor: pointer;
white-space: nowrap;
font-size: 16px;
padding: 0 5px 0 0;
padding: 13px;
z-index: 3;
&:hover {
text-decoration: underline;
}
&:last-child {
padding: 0 15px 0 0;
&.compact {
padding-inline-end: 5px;
flex: 0 0 auto;
}
}
.column-back-button__icon {
display: inline-block;
margin-inline-end: 5px;
}
.react-toggle {
display: inline-block;
position: relative;
@ -4013,7 +4011,7 @@ a.status-card {
z-index: 2;
outline: 0;
& > button {
&__title {
display: flex;
align-items: center;
gap: 5px;
@ -4035,8 +4033,18 @@ a.status-card {
}
}
& > .column-header__back-button {
.column-header__back-button + &__title {
padding-inline-start: 0;
}
.column-header__back-button {
flex: 1;
color: $highlight-text-color;
&.compact {
flex: 0 0 auto;
color: $primary-text-color;
}
}
&.active {
@ -4050,6 +4058,18 @@ a.status-card {
&:active {
outline: 0;
}
&__advanced-buttons {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
padding-top: 0;
&:first-child {
padding-top: 16px;
}
}
}
.column-header__buttons {
@ -4136,7 +4156,6 @@ a.status-card {
.column-header__collapsible-inner {
background: $ui-base-color;
padding: 15px;
}
.column-header__setting-btn {
@ -4158,20 +4177,8 @@ a.status-card {
}
.column-header__setting-arrows {
float: right;
.column-header__setting-btn {
padding: 5px;
&:first-child {
padding-inline-end: 7px;
}
&:last-child {
padding-inline-start: 7px;
margin-inline-start: 5px;
}
}
display: flex;
align-items: center;
}
.text-btn {
@ -4408,24 +4415,56 @@ a.status-card {
text-align: center;
}
.column-settings__outer {
background: lighten($ui-base-color, 8%);
padding: 15px;
}
.column-settings {
display: flex;
flex-direction: column;
.column-settings__section {
&__section {
// FIXME: Legacy
color: $darker-text-color;
cursor: default;
display: block;
font-weight: 500;
margin-bottom: 10px;
}
}
.column-settings__row--with-margin {
margin-bottom: 15px;
.column-header__links {
margin: 0;
}
section {
padding: 16px;
border-bottom: 1px solid lighten($ui-base-color, 8%);
&:last-child {
border-bottom: 0;
}
}
h3 {
font-size: 16px;
line-height: 24px;
letter-spacing: 0.5px;
font-weight: 500;
color: $primary-text-color;
margin-bottom: 16px;
}
&__row {
display: flex;
flex-direction: column;
gap: 12px;
}
.app-form__toggle {
&__toggle > div {
border: 0;
}
}
}
.column-settings__hashtags {
margin-top: 15px;
.column-settings__row {
margin-bottom: 15px;
}
@ -4549,16 +4588,13 @@ a.status-card {
}
.setting-toggle {
display: block;
line-height: 24px;
display: flex;
align-items: center;
gap: 8px;
}
.setting-toggle__label {
color: $darker-text-color;
display: inline-block;
margin-bottom: 14px;
margin-inline-start: 8px;
vertical-align: middle;
}
.limited-account-hint {
@ -6949,29 +6985,33 @@ a.status-card {
background: $ui-base-color;
&__column {
padding: 10px 15px;
padding-bottom: 0;
display: flex;
flex-direction: column;
gap: 15px;
padding: 15px;
}
.radio-button {
display: block;
display: flex;
}
}
.column-settings__row .radio-button {
display: block;
display: flex;
}
.radio-button {
font-size: 14px;
position: relative;
display: inline-block;
padding: 6px 0;
display: inline-flex;
align-items: center;
line-height: 18px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
gap: 10px;
color: $secondary-text-color;
input[type='radio'],
input[type='checkbox'] {
@ -6979,21 +7019,29 @@ a.status-card {
}
&__input {
display: inline-block;
display: block;
position: relative;
border: 1px solid $ui-primary-color;
border: 2px solid $secondary-text-color;
box-sizing: border-box;
width: 18px;
height: 18px;
flex: 0 0 auto;
margin-inline-end: 10px;
top: -1px;
border-radius: 50%;
vertical-align: middle;
&.checked {
border-color: lighten($ui-highlight-color, 4%);
background: lighten($ui-highlight-color, 4%);
border-color: $secondary-text-color;
&::before {
position: absolute;
left: 2px;
top: 2px;
content: '';
display: block;
border-radius: 50%;
width: 10px;
height: 10px;
background: $secondary-text-color;
}
}
}
}
@ -9588,3 +9636,110 @@ noscript {
}
}
}
.filtered-notifications-banner {
display: flex;
align-items: center;
background: $ui-base-color;
border-bottom: 1px solid lighten($ui-base-color, 8%);
padding: 15px;
gap: 15px;
color: $darker-text-color;
text-decoration: none;
&:hover,
&:active,
&:focus {
color: $secondary-text-color;
.filtered-notifications-banner__badge {
background: $secondary-text-color;
}
}
.icon {
width: 24px;
height: 24px;
}
&__text {
flex: 1 1 auto;
font-style: 14px;
line-height: 20px;
strong {
font-size: 16px;
line-height: 24px;
display: block;
}
}
&__badge {
background: $darker-text-color;
color: $ui-base-color;
border-radius: 100px;
padding: 2px 8px;
font-weight: 500;
font-size: 11px;
line-height: 16px;
}
}
.notification-request {
display: flex;
align-items: center;
gap: 16px;
padding: 15px;
border-bottom: 1px solid lighten($ui-base-color, 8%);
&__link {
display: flex;
align-items: center;
gap: 12px;
flex: 1 1 auto;
text-decoration: none;
color: inherit;
overflow: hidden;
.account__avatar {
flex-shrink: 0;
}
}
&__name {
flex: 1 1 auto;
color: $darker-text-color;
font-style: 14px;
line-height: 20px;
overflow: hidden;
text-overflow: ellipsis;
&__display-name {
display: flex;
align-items: center;
gap: 6px;
font-size: 16px;
letter-spacing: 0.5px;
line-height: 24px;
color: $secondary-text-color;
}
.filtered-notifications-banner__badge {
background-color: $highlight-text-color;
border-radius: 4px;
padding: 1px 6px;
}
}
&__actions {
display: flex;
align-items: center;
gap: 8px;
.icon-button {
border-radius: 4px;
border: 1px solid lighten($ui-base-color, 8%);
padding: 5px;
}
}
}

View File

@ -1313,6 +1313,12 @@ code {
font-weight: 600;
}
.hint {
display: block;
font-size: 14px;
color: $darker-text-color;
}
.recommended {
position: absolute;
margin: 0 4px;

View File

@ -48,6 +48,6 @@ class NotificationRequest < ApplicationRecord
private
def prepare_notifications_count
self.notifications_count = Notification.where(account: account, from_account: from_account).limit(MAX_MEANINGFUL_COUNT).count
self.notifications_count = Notification.where(account: account, from_account: from_account, filtered: true).limit(MAX_MEANINGFUL_COUNT).count
end
end

View File

@ -27,7 +27,7 @@ Rails.application.routes.draw do
/public/remote
/conversations
/lists/(*any)
/notifications
/notifications/(*any)
/favourites
/bookmarks
/pinned

View File

@ -151,7 +151,7 @@ namespace :api, format: false do
end
namespace :notifications do
resources :requests, only: :index do
resources :requests, only: [:index, :show] do
member do
post :accept
post :dismiss

View File

@ -9,7 +9,7 @@ RSpec.describe NotificationPolicy do
let(:sender) { Fabricate(:account) }
before do
Fabricate.times(2, :notification, account: subject.account, activity: Fabricate(:status, account: sender))
Fabricate.times(2, :notification, account: subject.account, activity: Fabricate(:status, account: sender), filtered: true)
Fabricate(:notification_request, account: subject.account, from_account: sender)
subject.summarize!
end

View File

@ -10,7 +10,7 @@ RSpec.describe NotificationRequest do
context 'when there are remaining notifications' do
before do
Fabricate(:notification, account: subject.account, activity: Fabricate(:status, account: subject.from_account))
Fabricate(:notification, account: subject.account, activity: Fabricate(:status, account: subject.from_account), filtered: true)
subject.reconsider_existence!
end