[MM-60484 & MM-60485] Add disabled notification banners and section notices for web (#28372)

Этот коммит содержится в:
M-ZubairAhmed
2024-11-01 07:02:58 +05:30
коммит произвёл GitHub
родитель 2d2c039a27
Коммит 857fd87a47
29 изменённых файлов: 661 добавлений и 115 удалений

Просмотреть файл

@@ -45,7 +45,6 @@ function mapStateToProps(state: GlobalState) {
}; };
} }
//
function mapDispatchToProps(dispatch: Dispatch) { function mapDispatchToProps(dispatch: Dispatch) {
const dismissFirstError = dismissError.bind(null, 0); const dismissFirstError = dismissError.bind(null, 0);
return { return {

Просмотреть файл

@@ -0,0 +1,57 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`NotificationPermissionBar should render the NotificationPermissionNeverGrantedBar when permission is never granted yet 1`] = `
<div>
<div
class="_StyledDiv-BRlth ksRYxX announcement-bar"
>
<div
class="announcement-bar__text"
>
<i
class="icon icon-alert-circle-outline"
/>
<span>
We need your permission to show notifications in the browser.
</span>
<button>
Enable notifications
</button>
</div>
<a
class="announcement-bar__close"
href="#"
>
×
</a>
</div>
</div>
`;
exports[`NotificationPermissionBar should render the NotificationUnsupportedBar if notifications are not supported 1`] = `
<div>
<div
class="_StyledDiv-BRlth ksRYxX announcement-bar"
>
<div
class="announcement-bar__text"
>
<i
class="icon icon-alert-circle-outline"
/>
<span>
Your browser does not support browser notifications.
</span>
<button>
Update your browser
</button>
</div>
<a
class="announcement-bar__close"
href="#"
>
×
</a>
</div>
</div>
`;

Просмотреть файл

@@ -1,19 +1,13 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import {screen, waitFor} from '@testing-library/react';
import React from 'react'; import React from 'react';
import {renderWithContext, userEvent} from 'tests/react_testing_utils'; import {renderWithContext, userEvent, screen, waitFor} from 'tests/react_testing_utils';
import {requestNotificationPermission, isNotificationAPISupported} from 'utils/notifications'; import * as utilsNotifications from 'utils/notifications';
import NotificationPermissionBar from './index'; import NotificationPermissionBar from './index';
jest.mock('utils/notifications', () => ({
requestNotificationPermission: jest.fn(),
isNotificationAPISupported: jest.fn(),
}));
describe('NotificationPermissionBar', () => { describe('NotificationPermissionBar', () => {
const initialState = { const initialState = {
entities: { entities: {
@@ -27,52 +21,71 @@ describe('NotificationPermissionBar', () => {
}, },
}; };
beforeEach(() => {
(isNotificationAPISupported as jest.Mock).mockReturnValue(true);
(window as any).Notification = {permission: 'default'};
});
afterEach(() => { afterEach(() => {
jest.clearAllMocks(); jest.restoreAllMocks();
delete (window as any).Notification;
}); });
test('should render the notification bar when conditions are met', () => { test('should not render anything if user is not logged in', () => {
renderWithContext(<NotificationPermissionBar/>, initialState); const {container} = renderWithContext(<NotificationPermissionBar/>);
expect(screen.getByText('We need your permission to show desktop notifications.')).toBeInTheDocument(); expect(container).toBeEmptyDOMElement();
});
test('should render the NotificationUnsupportedBar if notifications are not supported', () => {
jest.spyOn(utilsNotifications, 'isNotificationAPISupported').mockReturnValue(false);
const {container} = renderWithContext(<NotificationPermissionBar/>, initialState);
expect(container).toMatchSnapshot();
expect(screen.queryByText('Your browser does not support browser notifications.')).toBeInTheDocument();
expect(screen.queryByText('Update your browser')).toBeInTheDocument();
});
test('should render the NotificationPermissionNeverGrantedBar when permission is never granted yet', () => {
jest.spyOn(utilsNotifications, 'isNotificationAPISupported').mockReturnValue(true);
jest.spyOn(utilsNotifications, 'getNotificationPermission').mockReturnValue(utilsNotifications.NotificationPermissionNeverGranted);
const {container} = renderWithContext(<NotificationPermissionBar/>, initialState);
expect(container).toMatchSnapshot();
expect(screen.getByText('We need your permission to show notifications in the browser.')).toBeInTheDocument();
expect(screen.getByText('Enable notifications')).toBeInTheDocument(); expect(screen.getByText('Enable notifications')).toBeInTheDocument();
}); });
test('should not render the notification bar if user is not logged in', () => { test('should call requestNotificationPermission and hide the bar when the button is clicked in NotificationPermissionNeverGrantedBar', async () => {
renderWithContext(<NotificationPermissionBar/>); jest.spyOn(utilsNotifications, 'isNotificationAPISupported').mockReturnValue(true);
jest.spyOn(utilsNotifications, 'getNotificationPermission').mockReturnValue(utilsNotifications.NotificationPermissionNeverGranted);
expect(screen.queryByText('We need your permission to show desktop notifications.')).not.toBeInTheDocument(); jest.spyOn(utilsNotifications, 'requestNotificationPermission').mockResolvedValue(utilsNotifications.NotificationPermissionGranted);
expect(screen.queryByText('Enable notifications')).not.toBeInTheDocument();
});
test('should not render the notification bar if Notifications are not supported', () => {
delete (window as any).Notification;
(isNotificationAPISupported as jest.Mock).mockReturnValue(false);
renderWithContext(<NotificationPermissionBar/>, initialState); renderWithContext(<NotificationPermissionBar/>, initialState);
expect(screen.queryByText('We need your permission to show desktop notifications.')).not.toBeInTheDocument(); expect(screen.getByText('We need your permission to show notifications in the browser.')).toBeInTheDocument();
expect(screen.queryByText('Enable notifications')).not.toBeInTheDocument();
});
test('should call requestNotificationPermission and hide the bar when the button is clicked', async () => {
(requestNotificationPermission as jest.Mock).mockResolvedValue('granted');
renderWithContext(<NotificationPermissionBar/>, initialState);
expect(screen.getByText('We need your permission to show desktop notifications.')).toBeInTheDocument();
await waitFor(async () => { await waitFor(async () => {
userEvent.click(screen.getByText('Enable notifications')); userEvent.click(screen.getByText('Enable notifications'));
}); });
expect(requestNotificationPermission).toHaveBeenCalled(); expect(utilsNotifications.requestNotificationPermission).toHaveBeenCalled();
expect(screen.queryByText('We need your permission to show desktop notifications.')).not.toBeInTheDocument(); expect(screen.queryByText('We need your permission to show browser notifications.')).not.toBeInTheDocument();
});
test('should not render anything if permission is denied', () => {
jest.spyOn(utilsNotifications, 'isNotificationAPISupported').mockReturnValue(true);
jest.spyOn(utilsNotifications, 'getNotificationPermission').mockReturnValue('denied');
const {container} = renderWithContext(<NotificationPermissionBar/>, initialState);
expect(container).toBeEmptyDOMElement();
});
test('should not render anything if permission is granted', () => {
jest.spyOn(utilsNotifications, 'isNotificationAPISupported').mockReturnValue(true);
jest.spyOn(utilsNotifications, 'getNotificationPermission').mockReturnValue('granted');
const {container} = renderWithContext(<NotificationPermissionBar/>, initialState);
expect(container).toBeEmptyDOMElement();
}); });
}); });

Просмотреть файл

@@ -1,57 +1,42 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import React, {useCallback, useState} from 'react'; import React from 'react';
import {FormattedMessage} from 'react-intl';
import {useSelector} from 'react-redux'; import {useSelector} from 'react-redux';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import AnnouncementBar from 'components/announcement_bar/default_announcement_bar'; import NotificationPermissionNeverGrantedBar from 'components/announcement_bar/notification_permission_bar/notification_permission_never_granted_bar';
import NotificationPermissionUnsupportedBar from 'components/announcement_bar/notification_permission_bar/notification_permission_unsupported_bar';
import {AnnouncementBarTypes} from 'utils/constants'; import {
import {requestNotificationPermission, isNotificationAPISupported} from 'utils/notifications'; isNotificationAPISupported,
NotificationPermissionDenied,
NotificationPermissionNeverGranted,
getNotificationPermission,
} from 'utils/notifications';
export default function NotificationPermissionBar() { export default function NotificationPermissionBar() {
const isLoggedIn = Boolean(useSelector(getCurrentUserId)); const isLoggedIn = Boolean(useSelector(getCurrentUserId));
const [show, setShow] = useState(isNotificationAPISupported() ? Notification.permission === 'default' : false); if (!isLoggedIn) {
const handleClick = useCallback(async () => {
await requestNotificationPermission();
setShow(false);
}, []);
const handleClose = useCallback(() => {
// If the user closes the bar, don't show the notification bar any more for the rest of the session, but
// show it again on app refresh.
setShow(false);
}, []);
if (!show || !isLoggedIn || !isNotificationAPISupported()) {
return null; return null;
} }
return ( // When browser does not support notification API, we show the notification bar to update browser
<AnnouncementBar if (!isNotificationAPISupported()) {
showCloseButton={true} return <NotificationPermissionUnsupportedBar/>;
handleClose={handleClose} }
type={AnnouncementBarTypes.ANNOUNCEMENT}
message={ // When user has not granted permission, we show the notification bar to request permission
<FormattedMessage if (getNotificationPermission() === NotificationPermissionNeverGranted) {
id='announcement_bar.notification.needs_permission' return <NotificationPermissionNeverGrantedBar/>;
defaultMessage='We need your permission to show desktop notifications.' }
/>
} // When user has denied permission, we don't show since user explicitly denied permission
ctaText={ if (getNotificationPermission() === NotificationPermissionDenied) {
<FormattedMessage return null;
id='announcement_bar.notification.enable_notifications' }
defaultMessage='Enable notifications'
/> return null;
}
showCTA={true}
showLinkAsButton={true}
onButtonClick={handleClick}
/>
);
} }

Просмотреть файл

@@ -0,0 +1,59 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useState} from 'react';
import {FormattedMessage} from 'react-intl';
import AnnouncementBar from 'components/announcement_bar/default_announcement_bar';
import {AnnouncementBarTypes} from 'utils/constants';
import {requestNotificationPermission} from 'utils/notifications';
export default function NotificationPermissionNeverGrantedBar() {
const [show, setShow] = useState(true);
const handleClick = useCallback(async () => {
try {
await requestNotificationPermission();
} catch (error) {
// eslint-disable-next-line no-console
console.error('Error requesting notification permission', error);
} finally {
// Dismiss the bar after user makes a choice
setShow(false);
}
}, []);
const handleClose = useCallback(() => {
// If the user closes the bar, don't show the notification bar any more for the rest of the session, but
// show it again on app refresh.
setShow(false);
}, []);
if (!show) {
return null;
}
return (
<AnnouncementBar
showCloseButton={true}
handleClose={handleClose}
type={AnnouncementBarTypes.ANNOUNCEMENT}
message={
<FormattedMessage
id='announcementBar.notification.permissionNeverGrantedBar.message'
defaultMessage='We need your permission to show notifications in the browser.'
/>
}
ctaText={
<FormattedMessage
id='announcementBar.notification.permissionNeverGrantedBar.cta'
defaultMessage='Enable notifications'
/>
}
showCTA={true}
showLinkAsButton={true}
onButtonClick={handleClick}
/>
);
}

Просмотреть файл

@@ -0,0 +1,50 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useState} from 'react';
import {FormattedMessage} from 'react-intl';
import AnnouncementBar from 'components/announcement_bar/default_announcement_bar';
import {AnnouncementBarTypes} from 'utils/constants';
export default function UnsupportedNotificationAnnouncementBar() {
const [show, setShow] = useState(true);
const handleClick = useCallback(async () => {
window.open('https://mattermost.com/pl/pc-web-requirements', '_blank', 'noopener,noreferrer');
}, []);
const handleClose = useCallback(() => {
// If the user closes the bar, don't show the notification bar any more for the rest of the session, but
// show it again on app refresh.
setShow(false);
}, []);
if (!show) {
return null;
}
return (
<AnnouncementBar
showCloseButton={true}
type={AnnouncementBarTypes.ANNOUNCEMENT}
handleClose={handleClose}
message={
<FormattedMessage
id='announcementBar.notification.unsupportedBar.message'
defaultMessage='Your browser does not support browser notifications.'
/>
}
ctaText={
<FormattedMessage
id='announcementBar.notification.unsupportedBar.cta'
defaultMessage='Update your browser'
/>
}
showCTA={true}
showLinkAsButton={true}
onButtonClick={handleClick}
/>
);
}

Просмотреть файл

@@ -559,7 +559,7 @@ exports[`components/drafts/panel/panel_body should match snapshot for priority 1
uppercase={true} uppercase={true}
> >
<div <div
className="TagWrapper-keYggn hpsCJu Tag Tag--info Tag--xs" className="TagWrapper-keYggn gnIgwl Tag Tag--info Tag--xs"
> >
<AlertCircleOutlineIcon <AlertCircleOutlineIcon
size={10} size={10}

Просмотреть файл

@@ -23,6 +23,10 @@ function getBaseProps(): Props {
onClick: jest.fn(), onClick: jest.fn(),
text: 'secondary button title', text: 'secondary button title',
}, },
tertiaryButton: {
onClick: jest.fn(),
text: 'tertiary button title',
},
linkButton: { linkButton: {
onClick: jest.fn(), onClick: jest.fn(),
text: 'link button title', text: 'link button title',
@@ -39,11 +43,13 @@ describe('PluginAction', () => {
renderWithContext(<SectionNotice {...props}/>); renderWithContext(<SectionNotice {...props}/>);
const primaryButton = screen.getByText(props.primaryButton!.text); const primaryButton = screen.getByText(props.primaryButton!.text);
const secondaryButton = screen.getByText(props.secondaryButton!.text); const secondaryButton = screen.getByText(props.secondaryButton!.text);
const tertiaryButton = screen.getByText(props.tertiaryButton!.text);
const linkButton = screen.getByText(props.linkButton!.text); const linkButton = screen.getByText(props.linkButton!.text);
const closeButton = screen.getByLabelText('Dismiss notice'); const closeButton = screen.getByLabelText('Dismiss notice');
expect(primaryButton).toBeInTheDocument(); expect(primaryButton).toBeInTheDocument();
expect(secondaryButton).toBeInTheDocument(); expect(secondaryButton).toBeInTheDocument();
expect(tertiaryButton).toBeInTheDocument();
expect(linkButton).toBeInTheDocument(); expect(linkButton).toBeInTheDocument();
expect(closeButton).toBeInTheDocument(); expect(closeButton).toBeInTheDocument();
expect(screen.queryByText(props.text as string)).toBeInTheDocument(); expect(screen.queryByText(props.text as string)).toBeInTheDocument();
@@ -52,6 +58,8 @@ describe('PluginAction', () => {
expect(props.primaryButton?.onClick).toHaveBeenCalledTimes(1); expect(props.primaryButton?.onClick).toHaveBeenCalledTimes(1);
fireEvent.click(secondaryButton); fireEvent.click(secondaryButton);
expect(props.secondaryButton?.onClick).toHaveBeenCalledTimes(1); expect(props.secondaryButton?.onClick).toHaveBeenCalledTimes(1);
fireEvent.click(tertiaryButton);
expect(props.tertiaryButton?.onClick).toHaveBeenCalledTimes(1);
fireEvent.click(linkButton); fireEvent.click(linkButton);
expect(props.linkButton?.onClick).toHaveBeenCalledTimes(1); expect(props.linkButton?.onClick).toHaveBeenCalledTimes(1);
fireEvent.click(closeButton); fireEvent.click(closeButton);
@@ -62,6 +70,7 @@ describe('PluginAction', () => {
const props = getBaseProps(); const props = getBaseProps();
props.primaryButton = undefined; props.primaryButton = undefined;
props.secondaryButton = undefined; props.secondaryButton = undefined;
props.tertiaryButton = undefined;
props.linkButton = undefined; props.linkButton = undefined;
props.isDismissable = false; props.isDismissable = false;
renderWithContext(<SectionNotice {...props}/>); renderWithContext(<SectionNotice {...props}/>);

Просмотреть файл

@@ -17,6 +17,7 @@ type Props = {
text?: string; text?: string;
primaryButton?: SectionNoticeButtonProp; primaryButton?: SectionNoticeButtonProp;
secondaryButton?: SectionNoticeButtonProp; secondaryButton?: SectionNoticeButtonProp;
tertiaryButton?: SectionNoticeButtonProp;
linkButton?: SectionNoticeButtonProp; linkButton?: SectionNoticeButtonProp;
type?: 'info' | 'success' | 'danger' | 'welcome' | 'warning' | 'hint'; type?: 'info' | 'success' | 'danger' | 'welcome' | 'warning' | 'hint';
isDismissable?: boolean; isDismissable?: boolean;
@@ -37,6 +38,7 @@ const SectionNotice = ({
text, text,
primaryButton, primaryButton,
secondaryButton, secondaryButton,
tertiaryButton,
linkButton, linkButton,
type = 'info', type = 'info',
isDismissable, isDismissable,
@@ -45,7 +47,7 @@ const SectionNotice = ({
const intl = useIntl(); const intl = useIntl();
const icon = iconByType[type]; const icon = iconByType[type];
const showDismiss = Boolean(isDismissable && onDismissClick); const showDismiss = Boolean(isDismissable && onDismissClick);
const hasButtons = Boolean(primaryButton || secondaryButton || linkButton); const hasButtons = Boolean(primaryButton || secondaryButton || tertiaryButton || linkButton);
return ( return (
<div className={classNames('sectionNoticeContainer', type)}> <div className={classNames('sectionNoticeContainer', type)}>
<div className={'sectionNoticeContent'}> <div className={'sectionNoticeContent'}>
@@ -64,9 +66,15 @@ const SectionNotice = ({
{secondaryButton && {secondaryButton &&
<SectionNoticeButton <SectionNoticeButton
button={secondaryButton} button={secondaryButton}
buttonClass='btn-tertiary' buttonClass='btn-secondary'
/> />
} }
{tertiaryButton && (
<SectionNoticeButton
button={tertiaryButton}
buttonClass='btn-tertiary'
/>
)}
{linkButton && {linkButton &&
<SectionNoticeButton <SectionNoticeButton
button={linkButton} button={linkButton}

Просмотреть файл

@@ -74,7 +74,7 @@
} }
.sectionNoticeIcon { .sectionNoticeIcon {
font-size: 20px; font-size: 24px;
&.info, &.hint { &.info, &.hint {
color: var(--sidebar-text-active-border); color: var(--sidebar-text-active-border);
@@ -99,7 +99,7 @@
font-family: 'Open Sans'; font-family: 'Open Sans';
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
line-height: 20px; line-height: 24px;
&.welcome { &.welcome {
font-family: 'Metropolis'; font-family: 'Metropolis';

Просмотреть файл

@@ -8,7 +8,7 @@ import type {SectionNoticeButtonProp} from './types';
type Props = { type Props = {
button: SectionNoticeButtonProp; button: SectionNoticeButtonProp;
buttonClass: 'btn-primary' | 'btn-tertiary' | 'btn-link'; buttonClass: 'btn-primary' | 'btn-secondary' | 'btn-tertiary' | 'btn-link';
} }
const SectionNoticeButton = ({ const SectionNoticeButton = ({

Просмотреть файл

@@ -45,6 +45,7 @@ type Props = {
submitExtra?: ReactNode; submitExtra?: ReactNode;
saving?: boolean; saving?: boolean;
title?: ReactNode; title?: ReactNode;
extraContentBeforeSettingList?: ReactNode;
isFullWidth?: boolean; isFullWidth?: boolean;
cancelButtonText?: ReactNode; cancelButtonText?: ReactNode;
shiftEnter?: boolean; shiftEnter?: boolean;
@@ -224,6 +225,7 @@ export default class SettingItemMax extends React.PureComponent<Props> {
className={`section-max form-horizontal ${this.props.containerStyle}`} className={`section-max form-horizontal ${this.props.containerStyle}`}
> >
{title} {title}
{this.props.extraContentBeforeSettingList}
<div <div
className={classNames('sectionContent', { className={classNames('sectionContent', {
'col-sm-12': this.props.isFullWidth, 'col-sm-12': this.props.isFullWidth,

Просмотреть файл

@@ -92,6 +92,7 @@ Object {
id="desktopAndMobileTitle" id="desktopAndMobileTitle"
> >
Desktop and mobile notifications Desktop and mobile notifications
<div />
</h4> </h4>
<button <button
aria-expanded="false" aria-expanded="false"
@@ -351,6 +352,7 @@ Object {
id="desktopAndMobileTitle" id="desktopAndMobileTitle"
> >
Desktop and mobile notifications Desktop and mobile notifications
<div />
</h4> </h4>
<button <button
aria-expanded="false" aria-expanded="false"
@@ -669,6 +671,7 @@ Object {
id="desktopAndMobileTitle" id="desktopAndMobileTitle"
> >
Desktop and mobile notifications Desktop and mobile notifications
<div />
</h4> </h4>
<button <button
aria-expanded="false" aria-expanded="false"
@@ -931,6 +934,7 @@ Object {
id="desktopAndMobileTitle" id="desktopAndMobileTitle"
> >
Desktop and mobile notifications Desktop and mobile notifications
<div />
</h4> </h4>
<button <button
aria-expanded="false" aria-expanded="false"
@@ -1252,6 +1256,7 @@ Object {
id="desktopAndMobileTitle" id="desktopAndMobileTitle"
> >
Desktop and mobile notifications Desktop and mobile notifications
<div />
</h4> </h4>
<button <button
aria-expanded="false" aria-expanded="false"
@@ -1476,6 +1481,7 @@ Object {
id="desktopAndMobileTitle" id="desktopAndMobileTitle"
> >
Desktop and mobile notifications Desktop and mobile notifications
<div />
</h4> </h4>
<button <button
aria-expanded="false" aria-expanded="false"

Просмотреть файл

@@ -11,6 +11,7 @@ exports[`DesktopNotificationSettings should match snapshot, on max setting 1`] =
> >
Desktop and mobile notifications Desktop and mobile notifications
</h4> </h4>
<div />
<div <div
class="sectionContent col-sm-10 col-sm-offset-2" class="sectionContent col-sm-10 col-sm-offset-2"
> >
@@ -179,6 +180,7 @@ exports[`DesktopNotificationSettings should match snapshot, on min setting 1`] =
id="desktopAndMobileTitle" id="desktopAndMobileTitle"
> >
Desktop and mobile notifications Desktop and mobile notifications
<div />
</h4> </h4>
<button <button
aria-expanded="false" aria-expanded="false"
@@ -214,6 +216,7 @@ exports[`DesktopNotificationSettings should not show desktop thread notification
> >
Desktop and mobile notifications Desktop and mobile notifications
</h4> </h4>
<div />
<div <div
class="sectionContent col-sm-10 col-sm-offset-2" class="sectionContent col-sm-10 col-sm-offset-2"
> >

Просмотреть файл

@@ -17,6 +17,9 @@ import DesktopNotificationSettings, {
const validNotificationLevels = Object.values(NotificationLevels); const validNotificationLevels = Object.values(NotificationLevels);
jest.mock('components/user_settings/notifications/desktop_and_mobile_notification_setting/notification_permission_section_notice', () => () => <div/>);
jest.mock('components/user_settings/notifications/desktop_and_mobile_notification_setting/notification_permission_title_tag', () => () => <div/>);
describe('DesktopNotificationSettings', () => { describe('DesktopNotificationSettings', () => {
const baseProps: Props = { const baseProps: Props = {
active: true, active: true,

Просмотреть файл

@@ -12,6 +12,8 @@ import type {UserNotifyProps} from '@mattermost/types/users';
import SettingItemMax from 'components/setting_item_max'; import SettingItemMax from 'components/setting_item_max';
import SettingItemMin from 'components/setting_item_min'; import SettingItemMin from 'components/setting_item_min';
import type SettingItemMinComponent from 'components/setting_item_min'; import type SettingItemMinComponent from 'components/setting_item_min';
import NotificationPermissionSectionNotice from 'components/user_settings/notifications/desktop_and_mobile_notification_setting/notification_permission_section_notice';
import NotificationPermissionTitleTag from 'components/user_settings/notifications/desktop_and_mobile_notification_setting/notification_permission_title_tag';
import Constants, {NotificationLevels, UserSettingsNotificationSections} from 'utils/constants'; import Constants, {NotificationLevels, UserSettingsNotificationSections} from 'utils/constants';
@@ -322,6 +324,7 @@ function DesktopAndMobileNotificationSettings({
saving={saving} saving={saving}
serverError={error} serverError={error}
updateSection={handleChangeForMaxSection} updateSection={handleChangeForMaxSection}
extraContentBeforeSettingList={<NotificationPermissionSectionNotice/>}
/> />
); );
} }
@@ -330,10 +333,13 @@ function DesktopAndMobileNotificationSettings({
<SettingItemMin <SettingItemMin
ref={editButtonRef} ref={editButtonRef}
title={ title={
<FormattedMessage <>
id='user.settings.notifications.desktopAndMobile.title' <FormattedMessage
defaultMessage='Desktop and mobile notifications' id='user.settings.notifications.desktopAndMobile.title'
/> defaultMessage='Desktop and mobile notifications'
/>
<NotificationPermissionTitleTag/>
</>
} }
describe={getCollapsedText(desktopActivity, pushActivity)} describe={getCollapsedText(desktopActivity, pushActivity)}
section={UserSettingsNotificationSections.DESKTOP_AND_MOBILE} section={UserSettingsNotificationSections.DESKTOP_AND_MOBILE}

Просмотреть файл

@@ -0,0 +1,50 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {renderWithContext, screen} from 'tests/react_testing_utils';
import * as utilsNotifications from 'utils/notifications';
import NotificationPermissionSectionNotice from './index';
describe('NotificationPermissionSectionNotice', () => {
afterEach(() => {
jest.restoreAllMocks();
});
test('should render "Unsupported" notice when notifications are not supported', () => {
jest.spyOn(utilsNotifications, 'isNotificationAPISupported').mockReturnValue(false);
renderWithContext(<NotificationPermissionSectionNotice/>);
expect(screen.getByText('Browser notifications unsupported')).toBeInTheDocument();
});
test('should render "Never granted" notice when notifications are never granted', () => {
jest.spyOn(utilsNotifications, 'isNotificationAPISupported').mockReturnValue(true);
jest.spyOn(utilsNotifications, 'getNotificationPermission').mockReturnValue('default');
renderWithContext(<NotificationPermissionSectionNotice/>);
expect(screen.getByText('Browser notifications are disabled')).toBeInTheDocument();
});
test('should render "Denied" notice when notifications are denied', () => {
jest.spyOn(utilsNotifications, 'isNotificationAPISupported').mockReturnValue(true);
jest.spyOn(utilsNotifications, 'getNotificationPermission').mockReturnValue('denied');
renderWithContext(<NotificationPermissionSectionNotice/>);
expect(screen.getByText('Browser notification permission was denied')).toBeInTheDocument();
});
test('should render nothing when notifications are granted', () => {
jest.spyOn(utilsNotifications, 'isNotificationAPISupported').mockReturnValue(true);
jest.spyOn(utilsNotifications, 'getNotificationPermission').mockReturnValue('granted');
const {container} = renderWithContext(<NotificationPermissionSectionNotice/>);
expect(container).toBeEmptyDOMElement();
});
});

Просмотреть файл

@@ -0,0 +1,35 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState} from 'react';
import NotificationPermissionDeniedNotice from 'components/user_settings/notifications/desktop_and_mobile_notification_setting/notification_permission_section_notice/notification_permission_denied_section_notice';
import NotificationPermissionNeverGrantedNotice from 'components/user_settings/notifications/desktop_and_mobile_notification_setting/notification_permission_section_notice/notification_permission_never_granted_section_notice';
import NotificationPermissionUnsupportedSectionNotice from 'components/user_settings/notifications/desktop_and_mobile_notification_setting/notification_permission_section_notice/notification_permission_unsupported_section_notice';
import {getNotificationPermission, isNotificationAPISupported, NotificationPermissionDenied, NotificationPermissionNeverGranted} from 'utils/notifications';
export default function NotificationPermissionSectionNotice() {
const isNotificationSupported = isNotificationAPISupported();
const [notificationPermission, setNotificationPermission] = useState(getNotificationPermission());
function handleRequestNotificationClicked(permission: NotificationPermission) {
setNotificationPermission(permission);
}
if (!isNotificationSupported) {
return <NotificationPermissionUnsupportedSectionNotice/>;
}
if (isNotificationSupported && notificationPermission === NotificationPermissionNeverGranted) {
return <NotificationPermissionNeverGrantedNotice onCtaButtonClick={handleRequestNotificationClicked}/>;
}
if (isNotificationSupported && notificationPermission === NotificationPermissionDenied) {
return <NotificationPermissionDeniedNotice/>;
}
return null;
}

Просмотреть файл

@@ -0,0 +1,38 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import SectionNotice from 'components/section_notice';
export default function NotificationPermissionDeniedSectionNotice() {
const intl = useIntl();
const handleClick = useCallback(() => {
window.open('https://mattermost.com/pl/manage-notifications', '_blank', 'noopener,noreferrer');
}, []);
return (
<div className='extraContentBeforeSettingList'>
<SectionNotice
type='danger'
title={intl.formatMessage({
id: 'user.settings.notifications.desktopAndMobile.notificationSection.permissionDenied.title',
defaultMessage: 'Browser notification permission was denied',
})}
text={intl.formatMessage({
id: 'user.settings.notifications.desktopAndMobile.notificationSection.permissionDenied.message',
defaultMessage: 'You\'re missing important message and call notifications from Mattermost. To start receiving notifications, please enable notifications for Mattermost in your browser settings.',
})}
tertiaryButton={{
text: intl.formatMessage({
id: 'user.settings.notifications.desktopAndMobile.notificationSection.permissionDenied.button',
defaultMessage: 'How to enable notifications',
}),
onClick: handleClick,
}}
/>
</div>
);
}

Просмотреть файл

@@ -0,0 +1,48 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import SectionNotice from 'components/section_notice';
import {requestNotificationPermission} from 'utils/notifications';
type Props = {
onCtaButtonClick: (permission: NotificationPermission) => void;
}
export default function NotificationPermissionNeverGrantedSectionNotice(props: Props) {
const intl = useIntl();
const handleClick = useCallback(async () => {
const permission = await requestNotificationPermission();
if (permission) {
props.onCtaButtonClick(permission);
}
}, [props.onCtaButtonClick]);
return (
<div className='extraContentBeforeSettingList'>
<SectionNotice
type='danger'
title={intl.formatMessage({
id: 'user.settings.notifications.desktopAndMobile.notificationSection.permissionNeverGranted.title',
defaultMessage: 'Browser notifications are disabled',
})}
text={intl.formatMessage({
id: 'user.settings.notifications.desktopAndMobile.notificationSection.permissionNeverGranted.message',
defaultMessage: 'You\'re missing important message and call notifications from Mattermost. Mattermost notifications are disabled by this browser.',
})}
primaryButton={{
text: intl.formatMessage({
id: 'user.settings.notifications.desktopAndMobile.notificationSection.permissionNeverGranted.button',
defaultMessage: 'Enable notifications',
}),
onClick: handleClick,
}}
/>
</div>
);
}

Просмотреть файл

@@ -0,0 +1,39 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import SectionNotice from 'components/section_notice';
export default function NotificationPermissionUnsupportedSectionNotice() {
const intl = useIntl();
const handleClick = useCallback(async () => {
window.open('https://mattermost.com/pl/pc-web-requirements', '_blank', 'noopener,noreferrer');
}, []);
return (
<div className='extraContentBeforeSettingList'>
<SectionNotice
type='danger'
title={intl.formatMessage({
id: 'user.settings.notifications.desktopAndMobile.notificationSection.permissionUnsupported.title',
defaultMessage: 'Browser notifications unsupported',
})}
text={intl.formatMessage({
id: 'user.settings.notifications.desktopAndMobile.notificationSection.permissionUnsupported.message',
defaultMessage: 'You\'re missing important message and call notifications from Mattermost. To start receiving notifications, please update to a supported browser.',
})}
tertiaryButton={{
text: intl.formatMessage({
id: 'user.settings.notifications.desktopAndMobile.notificationSection.permissionUnsupported.button',
defaultMessage: 'Update your browser',
}),
onClick: handleClick,
}}
/>
</div>
);
}

Просмотреть файл

@@ -0,0 +1,50 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {renderWithContext, screen} from 'tests/react_testing_utils';
import * as utilsNotifications from 'utils/notifications';
import NotificationPermissionTitleTag from './index';
describe('NotificationPermissionTitleTag', () => {
afterEach(() => {
jest.restoreAllMocks();
});
test('should render "Not supported" tag when notifications are not supported', () => {
jest.spyOn(utilsNotifications, 'isNotificationAPISupported').mockReturnValue(false);
renderWithContext(<NotificationPermissionTitleTag/>);
expect(screen.queryByText('Not supported')).toBeInTheDocument();
});
test('should render "Permission required" tag when permission is never granted yet', () => {
jest.spyOn(utilsNotifications, 'isNotificationAPISupported').mockReturnValue(true);
jest.spyOn(utilsNotifications, 'getNotificationPermission').mockReturnValue(utilsNotifications.NotificationPermissionNeverGranted);
renderWithContext(<NotificationPermissionTitleTag/>);
expect(screen.queryByText('Permission required')).toBeInTheDocument();
});
test('should render "Permission required" tag when permission is denied', () => {
jest.spyOn(utilsNotifications, 'isNotificationAPISupported').mockReturnValue(true);
jest.spyOn(utilsNotifications, 'getNotificationPermission').mockReturnValue(utilsNotifications.NotificationPermissionDenied);
renderWithContext(<NotificationPermissionTitleTag/>);
expect(screen.queryByText('Permission required')).toBeInTheDocument();
});
test('should render nothing when permission is granted', () => {
jest.spyOn(utilsNotifications, 'isNotificationAPISupported').mockReturnValue(true);
jest.spyOn(utilsNotifications, 'getNotificationPermission').mockReturnValue(utilsNotifications.NotificationPermissionGranted);
const {container} = renderWithContext(<NotificationPermissionTitleTag/>);
expect(container).toBeEmptyDOMElement();
});
});

Просмотреть файл

@@ -0,0 +1,51 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
import Tag from 'components/widgets/tag/tag';
import {
getNotificationPermission,
isNotificationAPISupported,
NotificationPermissionDenied,
NotificationPermissionNeverGranted,
} from 'utils/notifications';
export default function NotificationPermissionTitleTag() {
const {formatMessage} = useIntl();
if (!isNotificationAPISupported()) {
return (
<Tag
size='sm'
variant='danger'
icon='alert-outline'
text={formatMessage({
id: 'user.settings.notifications.desktopAndMobile.notificationSection.noPermissionIssueTag',
defaultMessage: 'Not supported',
})}
/>
);
}
if (
getNotificationPermission() === NotificationPermissionNeverGranted ||
getNotificationPermission() === NotificationPermissionDenied
) {
return (
<Tag
size='sm'
variant='dangerDim'
icon='alert-outline'
text={formatMessage({
id: 'user.settings.notifications.desktopAndMobile.notificationSection.permissionIssueTag',
defaultMessage: 'Permission required',
})}
/>
);
}
return null;
}

Просмотреть файл

@@ -10,6 +10,9 @@ import {TestHelper} from 'utils/test_helper';
import UserSettingsNotifications, {areDesktopAndMobileSettingsDifferent} from './user_settings_notifications'; import UserSettingsNotifications, {areDesktopAndMobileSettingsDifferent} from './user_settings_notifications';
jest.mock('components/user_settings/notifications/desktop_and_mobile_notification_setting/notification_permission_section_notice', () => () => <div/>);
jest.mock('components/user_settings/notifications/desktop_and_mobile_notification_setting/notification_permission_title_tag', () => () => <div/>);
describe('components/user_settings/display/UserSettingsDisplay', () => { describe('components/user_settings/display/UserSettingsDisplay', () => {
const defaultProps = { const defaultProps = {
user: TestHelper.getUserMock({id: 'user_id'}), user: TestHelper.getUserMock({id: 'user_id'}),

Просмотреть файл

@@ -9,7 +9,7 @@ import styled, {css} from 'styled-components';
import glyphMap from '@mattermost/compass-icons/components'; import glyphMap from '@mattermost/compass-icons/components';
import type {IconGlyphTypes} from '@mattermost/compass-icons/IconGlyphs'; import type {IconGlyphTypes} from '@mattermost/compass-icons/IconGlyphs';
export type TagVariant = 'info' | 'success' | 'warning' | 'danger'; export type TagVariant = 'info' | 'success' | 'warning' | 'danger' | 'dangerDim';
export type TagSize = 'xs' | 'sm' | 'md' | 'lg' export type TagSize = 'xs' | 'sm' | 'md' | 'lg'
@@ -26,10 +26,6 @@ type Props = {
type TagWrapperProps = Required<Pick<Props, 'uppercase'>>; type TagWrapperProps = Required<Pick<Props, 'uppercase'>>;
const TagWrapper = styled.div<TagWrapperProps>` const TagWrapper = styled.div<TagWrapperProps>`
--tag-bg: var(--semantic-color-general);
--tag-bg-opacity: 0.08;
--tag-color: var(--semantic-color-general);
appearance: none; appearance: none;
display: inline-flex; display: inline-flex;
@@ -84,38 +80,39 @@ const TagWrapper = styled.div<TagWrapperProps>`
padding: 2px 5px; padding: 2px 5px;
} }
&.Tag--info, background: rgba(var(--semantic-color-general), 0.08);
&.Tag--success, color: rgb(var(--semantic-color-general));
&.Tag--warning,
&.Tag--danger {
--tag-bg-opacity: 1;
--tag-color: 255, 255, 255;
}
&.Tag--info { &.Tag--info {
--tag-bg: var(--semantic-color-info); background: rgba(var(--semantic-color-info), 1);
color: rgb(255, 255, 255);
} }
&.Tag--success { &.Tag--success {
--tag-bg: var(--semantic-color-success); background: rgba(var(--semantic-color-success), 1);
color: rgb(255, 255, 255);
} }
&.Tag--warning { &.Tag--warning {
--tag-bg: var(--semantic-color-warning); background: rgba(var(--semantic-color-warning), 1);
color: rgb(255, 255, 255);
} }
&.Tag--danger { &.Tag--danger {
--tag-bg: var(--semantic-color-danger); background: rgba(var(--semantic-color-danger), 1);
color: rgb(255, 255, 255);
} }
background: rgba(var(--tag-bg), var(--tag-bg-opacity)); &.Tag--dangerDim {
color: rgb(var(--tag-color)); background: rgba(var(--semantic-color-danger), 0.08);
color: rgb(var(--semantic-color-danger));
}
${({onClick}) => typeof onClick === 'function' && ( ${({onClick}) => typeof onClick === 'function' && (
css` css`
&:hover, &:hover,
&:focus { &:focus {
background: rgba(var(--tag-bg), 0.08); background: rgba(var(--semantic-color-general), 0.08);
cursor: pointer; cursor: pointer;
} }
` `

Просмотреть файл

@@ -2892,12 +2892,14 @@
"announcement_bar.error.trial_license_expiring_last_day": "This is the last day of your free trial. Purchase a license now to continue using Mattermost Professional and Enterprise features.", "announcement_bar.error.trial_license_expiring_last_day": "This is the last day of your free trial. Purchase a license now to continue using Mattermost Professional and Enterprise features.",
"announcement_bar.error.trial_license_expiring_last_day.short": "This is the last day of your free trial.", "announcement_bar.error.trial_license_expiring_last_day.short": "This is the last day of your free trial.",
"announcement_bar.notification.email_verified": "Email verified", "announcement_bar.notification.email_verified": "Email verified",
"announcement_bar.notification.enable_notifications": "Enable notifications",
"announcement_bar.notification.needs_permission": "We need your permission to show desktop notifications.",
"announcement_bar.warn.contact_support_email": "<a>Contact support</a>.", "announcement_bar.warn.contact_support_email": "<a>Contact support</a>.",
"announcement_bar.warn.contact_support_text": "To renew your license, contact support at support@mattermost.com.", "announcement_bar.warn.contact_support_text": "To renew your license, contact support at support@mattermost.com.",
"announcement_bar.warn.no_internet_connection": "Looks like you do not have access to the internet.", "announcement_bar.warn.no_internet_connection": "Looks like you do not have access to the internet.",
"announcement_bar.warn.renew_license_contact_sales": "Contact sales", "announcement_bar.warn.renew_license_contact_sales": "Contact sales",
"announcementBar.notification.permissionNeverGrantedBar.cta": "Enable notifications",
"announcementBar.notification.permissionNeverGrantedBar.message": "We need your permission to show notifications in the browser.",
"announcementBar.notification.unsupportedBar.cta": "Update your browser",
"announcementBar.notification.unsupportedBar.message": "Your browser does not support browser notifications.",
"api.channel.add_guest.added": "{addedUsername} added to the channel as a guest by {username}.", "api.channel.add_guest.added": "{addedUsername} added to the channel as a guest by {username}.",
"api.channel.add_member.added": "{addedUsername} added to the channel by {username}.", "api.channel.add_member.added": "{addedUsername} added to the channel by {username}.",
"api.channel.delete_channel.archived": "{username} archived the channel.", "api.channel.delete_channel.archived": "{username} archived the channel.",
@@ -5661,6 +5663,17 @@
"user.settings.notifications.desktopAndMobile.noneDesktopButMobileMentions": "Never on desktop; mentions, direct messages, and group messages on mobile", "user.settings.notifications.desktopAndMobile.noneDesktopButMobileMentions": "Never on desktop; mentions, direct messages, and group messages on mobile",
"user.settings.notifications.desktopAndMobile.noneForDesktopAndMobile": "Never", "user.settings.notifications.desktopAndMobile.noneForDesktopAndMobile": "Never",
"user.settings.notifications.desktopAndMobile.nothing": "Nothing", "user.settings.notifications.desktopAndMobile.nothing": "Nothing",
"user.settings.notifications.desktopAndMobile.notificationSection.noPermissionIssueTag": "Not supported",
"user.settings.notifications.desktopAndMobile.notificationSection.permissionDenied.button": "How to enable notifications",
"user.settings.notifications.desktopAndMobile.notificationSection.permissionDenied.message": "You're missing important message and call notifications from Mattermost. To start receiving notifications, please enable notifications for Mattermost in your browser settings.",
"user.settings.notifications.desktopAndMobile.notificationSection.permissionDenied.title": "Browser notification permission was denied",
"user.settings.notifications.desktopAndMobile.notificationSection.permissionIssueTag": "Permission required",
"user.settings.notifications.desktopAndMobile.notificationSection.permissionNeverGranted.button": "Enable notifications",
"user.settings.notifications.desktopAndMobile.notificationSection.permissionNeverGranted.message": "You're missing important message and call notifications from Mattermost. Mattermost notifications are disabled by this browser.",
"user.settings.notifications.desktopAndMobile.notificationSection.permissionNeverGranted.title": "Browser notifications are disabled",
"user.settings.notifications.desktopAndMobile.notificationSection.permissionUnsupported.button": "Update your browser",
"user.settings.notifications.desktopAndMobile.notificationSection.permissionUnsupported.message": "You're missing important message and call notifications from Mattermost. To start receiving notifications, please update to a supported browser.",
"user.settings.notifications.desktopAndMobile.notificationSection.permissionUnsupported.title": "Browser notifications unsupported",
"user.settings.notifications.desktopAndMobile.notifyForDesktopthreads": "Notify me about replies to threads I'm following", "user.settings.notifications.desktopAndMobile.notifyForDesktopthreads": "Notify me about replies to threads I'm following",
"user.settings.notifications.desktopAndMobile.notifyForMobilethreads": "Notify me on mobile about replies to threads I'm following", "user.settings.notifications.desktopAndMobile.notifyForMobilethreads": "Notify me on mobile about replies to threads I'm following",
"user.settings.notifications.desktopAndMobile.noValidSettings": "Configure desktop and mobile settings", "user.settings.notifications.desktopAndMobile.noValidSettings": "Configure desktop and mobile settings",

Просмотреть файл

@@ -107,7 +107,7 @@
font-size: 12px; font-size: 12px;
font-style: normal; font-style: normal;
font-weight: 600; font-weight: 600;
line-height: 16px; line-height: 14px;
} }
} }

Просмотреть файл

@@ -255,6 +255,8 @@
.section-max { .section-max {
@include pie-clearfix; @include pie-clearfix;
display: flex;
flex-direction: column;
padding: 12px; padding: 12px;
margin-bottom: 0; margin-bottom: 0;
background: rgba(var(--center-channel-color-rgb), 0.04); background: rgba(var(--center-channel-color-rgb), 0.04);
@@ -267,6 +269,11 @@
line-height: 20px; line-height: 20px;
} }
.extraContentBeforeSettingList {
width: 100%;
padding-block-start: 20px;
}
.sectionContent { .sectionContent {
padding: 20px 0 0 0; padding: 20px 0 0 0;
@@ -774,10 +781,13 @@
} }
.section-min__title { .section-min__title {
display: flex;
flex-direction: row;
padding-right: 50px; padding-right: 50px;
margin: 0 0 5px; margin: 0 0 5px;
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
gap: 8px;
line-height: 20px; line-height: 20px;
&.isDisabled { &.isDisabled {

Просмотреть файл

@@ -105,7 +105,15 @@ export function isNotificationAPISupported() {
return ('Notification' in window) && (typeof Notification.requestPermission === 'function'); return ('Notification' in window) && (typeof Notification.requestPermission === 'function');
} }
export async function requestNotificationPermission() { export function getNotificationPermission(): NotificationPermission | null {
if (!isNotificationAPISupported()) {
return null;
}
return Notification.permission;
}
export async function requestNotificationPermission(): Promise<NotificationPermission | null> {
if (!isNotificationAPISupported()) { if (!isNotificationAPISupported()) {
return null; return null;
} }
@@ -117,3 +125,7 @@ export async function requestNotificationPermission() {
return null; return null;
} }
} }
export const NotificationPermissionNeverGranted = 'default';
export const NotificationPermissionGranted = 'granted';
export const NotificationPermissionDenied = 'denied';