Add hints if an error occurs subscribing to notifications (#143)

* Add hints if an error occurs subscribing to notifications

* Update import (type/linting issue)

* disable optimization for debugging

* Revert "disable optimization for debugging"

This reverts commit 5b837f94e97b7488a7dc565e7f74d399e19c286f.

* improve detection of notification support + improve ux / ui surrounding that

* update translations
This commit is contained in:
Dan Peterson 2021-11-01 13:51:23 -05:00 committed by GitHub
parent fa029e0c09
commit 704452732a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 249 additions and 117 deletions

View file

@ -2200,6 +2200,10 @@
"Dismiss": "Dismiss", "Dismiss": "Dismiss",
"Heads up: browser notifications are currently blocked in this browser.": "Heads up: browser notifications are currently blocked in this browser.", "Heads up: browser notifications are currently blocked in this browser.": "Heads up: browser notifications are currently blocked in this browser.",
"To enable push notifications please configure your browser to allow notifications on odysee.com.": "To enable push notifications please configure your browser to allow notifications on odysee.com.", "To enable push notifications please configure your browser to allow notifications on odysee.com.": "To enable push notifications please configure your browser to allow notifications on odysee.com.",
"There was an error enabling browser notifications. Please make sure your browser settings allow you to subscribe to notifications.": "There was an error enabling browser notifications. Please make sure your browser settings allow you to subscribe to notifications.", "Browser notifications aren't supported. Here's a few tips:": "Browser notifications aren't supported. Here's a few tips:",
"Notifications aren't available when in incognito or private mode.": "Notifications aren't available when in incognito or private mode.",
"On Firefox, notifications won't function if cookies are set to clear on browser close. Please disable or add an exception for Odysee, then refresh.": "On Firefox, notifications won't function if cookies are set to clear on browser close. Please disable or add an exception for Odysee, then refresh.",
"For Brave, enable google push notifications in settings.": "For Brave, enable google push notifications in settings.",
"Check browser settings to see if notifications are disabled or otherwise restricted.": "Check browser settings to see if notifications are disabled or otherwise restricted.",
"--end--": "--end--" "--end--": "--end--"
} }

View file

@ -7,16 +7,17 @@ type Props = {
subtitle?: string, subtitle?: string,
multirow?: boolean, // Displays the Value widget(s) below the Label instead of on the right. multirow?: boolean, // Displays the Value widget(s) below the Label instead of on the right.
useVerticalSeparator?: boolean, // Show a separator line between Label and Value. Useful when there are multiple Values. useVerticalSeparator?: boolean, // Show a separator line between Label and Value. Useful when there are multiple Values.
disabled?: boolean,
children?: React$Node, children?: React$Node,
}; };
export default function SettingsRow(props: Props) { export default function SettingsRow(props: Props) {
const { title, subtitle, multirow, useVerticalSeparator, children } = props; const { title, subtitle, multirow, useVerticalSeparator, disabled, children } = props;
return ( return (
<div <div
className={classnames('card__main-actions settings__row', { className={classnames('card__main-actions settings__row', {
'section__actions--between': !multirow, 'section__actions--between': !multirow,
'opacity-40': disabled,
})} })}
> >
<div className="settings__row--title"> <div className="settings__row--title">

View file

@ -59,7 +59,7 @@ export default function SubscribeButton(props: Props) {
} }
const claimName = channelName && '@' + channelName; const claimName = channelName && '@' + channelName;
const { pushSupported, pushEnabled, pushRequest } = useBrowserNotifications(); const { pushSupported, pushEnabled, pushRequest, pushErrorModal } = useBrowserNotifications();
const subscriptionHandler = isSubscribed ? doChannelUnsubscribe : doChannelSubscribe; const subscriptionHandler = isSubscribed ? doChannelUnsubscribe : doChannelSubscribe;
@ -125,6 +125,7 @@ export default function SubscribeButton(props: Props) {
}} }}
/> />
{isSubscribed && uiNotificationsEnabled && ( {isSubscribed && uiNotificationsEnabled && (
<>
<Button <Button
button="alt" button="alt"
icon={notificationsDisabled ? ICONS.BELL : ICONS.BELL_ON} icon={notificationsDisabled ? ICONS.BELL : ICONS.BELL_ON}
@ -155,6 +156,8 @@ export default function SubscribeButton(props: Props) {
} }
}} }}
/> />
{pushErrorModal()}
</>
)} )}
</div> </div>
) : null; ) : null;

View file

@ -19,7 +19,7 @@ import { doClearPublish } from 'redux/actions/publish';
import { Lbryio } from 'lbryinc'; import { Lbryio } from 'lbryinc';
import { selectFollowedTagsList } from 'redux/selectors/tags'; import { selectFollowedTagsList } from 'redux/selectors/tags';
import { doToast, doError, doNotificationList } from 'redux/actions/notifications'; import { doToast, doError, doNotificationList } from 'redux/actions/notifications';
import { pushReconnect, pushDisconnect, pushValidate } from '$web/src/push-notifications'; import pushNotifications from '$web/src/push-notifications';
import Native from 'native'; import Native from 'native';
import { import {
@ -537,8 +537,10 @@ export function doSignIn() {
const state = getState(); const state = getState();
const user = selectUser(state); const user = selectUser(state);
pushReconnect(user.id); if (pushNotifications.supported) {
pushValidate(user.id); pushNotifications.reconnect(user.id);
pushNotifications.validate(user.id);
}
const notificationsEnabled = SIMPLE_SITE || user.experimental_ui; const notificationsEnabled = SIMPLE_SITE || user.experimental_ui;
@ -562,7 +564,9 @@ export function doSignOut() {
const state = getState(); const state = getState();
const user = selectUser(state); const user = selectUser(state);
try { try {
await pushDisconnect(user.id); if (pushNotifications.supported) {
await pushNotifications.disconnect(user.id);
}
} finally { } finally {
Lbryio.call('user', 'signout') Lbryio.call('user', 'signout')
.then(doSignOutCleanup) .then(doSignOutCleanup)

View file

@ -0,0 +1,3 @@
.opacity-40 {
opacity: 0.4;
}

View file

@ -15,3 +15,9 @@
color: var(--color-text-subtitle); color: var(--color-text-subtitle);
font-size: var(--font-small); font-size: var(--font-small);
} }
.notificationsBlocked__subTextList {
li {
margin-left: var(--spacing-m);
}
}

View file

@ -8,7 +8,7 @@ import Button from 'component/button';
import usePersistedState from 'effects/use-persisted-state'; import usePersistedState from 'effects/use-persisted-state';
export const BrowserNotificationBanner = () => { export const BrowserNotificationBanner = () => {
const { pushSupported, pushEnabled, pushPermission, pushToggle } = useBrowserNotifications(); const { pushSupported, pushEnabled, pushPermission, pushToggle, pushErrorModal } = useBrowserNotifications();
const [hasAcknowledgedPush, setHasAcknowledgedPush] = usePersistedState('push-nag', false); const [hasAcknowledgedPush, setHasAcknowledgedPush] = usePersistedState('push-nag', false);
if (!pushSupported || pushEnabled || pushPermission === 'denied' || hasAcknowledgedPush) return null; if (!pushSupported || pushEnabled || pushPermission === 'denied' || hasAcknowledgedPush) return null;
@ -16,6 +16,7 @@ export const BrowserNotificationBanner = () => {
const handleClose = () => setHasAcknowledgedPush(true); const handleClose = () => setHasAcknowledgedPush(true);
return ( return (
<>
<div className="browserNotificationsBanner notice-message"> <div className="browserNotificationsBanner notice-message">
<div className="browserNotificationsBanner__overview"> <div className="browserNotificationsBanner__overview">
<Icon className="browserNotificationsBanner__icon" icon={ICONS.NOTIFICATION} size={32} /> <Icon className="browserNotificationsBanner__icon" icon={ICONS.NOTIFICATION} size={32} />
@ -36,6 +37,8 @@ export const BrowserNotificationBanner = () => {
<Button button="close" title={__('Dismiss')} icon={ICONS.REMOVE} onClick={handleClose} /> <Button button="close" title={__('Dismiss')} icon={ICONS.REMOVE} onClick={handleClose} />
</div> </div>
</div> </div>
{pushErrorModal()}
</>
); );
}; };

View file

@ -0,0 +1,61 @@
// @flow
import * as React from 'react';
import * as ICONS from 'constants/icons';
import Icon from 'component/common/icon';
import { Modal } from 'modal/modal';
import 'scss/component/notifications-blocked.scss';
type InlineMessageProps = {
title: string,
children: React.Node,
};
const InlineMessage = (props: InlineMessageProps) => {
const { title, children } = props;
return (
<div className="notificationsBlocked">
<Icon className="notificationsBlocked__icon" color="#E50054" icon={ICONS.ALERT} size={32} />
<div>
<span>{title}</span>
<span className={'notificationsBlocked__subText'}>{children}</span>
</div>
</div>
);
};
export const BrowserNotificationsBlocked = () => {
return (
<InlineMessage title={__('Heads up: browser notifications are currently blocked in this browser.')}>
{__('To enable push notifications please configure your browser to allow notifications on odysee.com.')}
</InlineMessage>
);
};
export const BrowserNotificationHints = () => {
return (
<InlineMessage title={__("Browser notifications aren't supported. Here's a few tips:")}>
<ul className={'notificationsBlocked__subText notificationsBlocked__subTextList'}>
<li>{__("Notifications aren't available when in incognito or private mode.")}</li>
<li>
{__(
"On Firefox, notifications won't function if cookies are set to clear on browser close. Please disable or add an exception for Odysee, then refresh."
)}
</li>
<li>{__('For Brave, enable google push notifications in settings.')}</li>
<li>{__('Check browser settings to see if notifications are disabled or otherwise restricted.')}</li>
</ul>
</InlineMessage>
);
};
type ModalProps = {
doHideModal: () => void,
};
export const BrowserNotificationErrorModal = (props: ModalProps) => {
const { doHideModal } = props;
return (
<Modal type="card" isOpen onAborted={doHideModal}>
<BrowserNotificationHints />
</Modal>
);
};

View file

@ -1,39 +1,47 @@
// @flow // @flow
import { useEffect, useState, useMemo } from 'react'; import React, { useEffect, useState, useMemo } from 'react';
import { pushSubscribe, pushUnsubscribe, pushIsSubscribed } from '$web/src/push-notifications'; import pushNotifications from '$web/src/push-notifications';
import { isSupported } from 'firebase/messaging'; import { BrowserNotificationErrorModal } from '$web/component/browserNotificationHints';
// @todo: Once we are on Redux 7 we should have proper hooks we can use here for store access. // @todo: Once we are on Redux 7 we should have proper hooks we can use here for store access.
import { store } from '$ui/store'; import { store } from '$ui/store';
import { selectUser } from 'redux/selectors/user'; import { selectUser } from 'redux/selectors/user';
import { doToast } from 'redux/actions/notifications';
export default () => { export default () => {
const [pushPermission, setPushPermission] = useState(window.Notification?.permission); const [pushPermission, setPushPermission] = useState(window.Notification?.permission);
const [subscribed, setSubscribed] = useState(false); const [subscribed, setSubscribed] = useState(false);
const [pushEnabled, setPushEnabled] = useState(false); const [pushEnabled, setPushEnabled] = useState(false);
const [pushSupported, setPushSupported] = useState(false); const [pushSupported, setPushSupported] = useState(false);
const [encounteredError, setEncounteredError] = useState(false);
const [user] = useState(selectUser(store.getState())); const [user] = useState(selectUser(store.getState()));
useEffect(() => { useEffect(() => {
pushIsSubscribed(user.id).then((isSubscribed: boolean) => setSubscribed(isSubscribed)); setPushSupported(pushNotifications.supported);
isSupported().then((supported: boolean) => setPushSupported(supported)); if (pushNotifications.supported) {
pushNotifications.subscribed(user.id).then((isSubscribed: boolean) => setSubscribed(isSubscribed));
}
}, [user]); }, [user]);
useMemo(() => setPushEnabled(pushPermission === 'granted' && subscribed), [pushPermission, subscribed]); useMemo(() => setPushEnabled(pushPermission === 'granted' && subscribed), [pushPermission, subscribed]);
const subscribe = async () => { const subscribe = async () => {
if (await pushSubscribe(user.id)) { setEncounteredError(false);
try {
if (await pushNotifications.subscribe(user.id)) {
setSubscribed(true); setSubscribed(true);
setPushPermission(window.Notification?.permission); setPushPermission(window.Notification?.permission);
return true;
} else { } else {
showError(); setEncounteredError(true);
}
} catch {
setEncounteredError(true);
} }
}; };
const unsubscribe = async () => { const unsubscribe = async () => {
if (await pushUnsubscribe(user.id)) { if (await pushNotifications.unsubscribe(user.id)) {
setSubscribed(false); setSubscribed(false);
} }
}; };
@ -46,15 +54,8 @@ export default () => {
return window.Notification?.permission !== 'granted' ? subscribe() : null; return window.Notification?.permission !== 'granted' ? subscribe() : null;
}; };
const showError = () => { const pushErrorModal = () => {
store.dispatch( return <>{encounteredError && <BrowserNotificationErrorModal doHideModal={() => setEncounteredError(false)} />}</>;
doToast({
isError: true,
message: __(
'There was an error enabling browser notifications. Please make sure your browser settings allow you to subscribe to notifications.'
),
})
);
}; };
return { return {
@ -63,5 +64,6 @@ export default () => {
pushPermission, pushPermission,
pushToggle, pushToggle,
pushRequest, pushRequest,
pushErrorModal,
}; };
}; };

View file

@ -1,39 +1,37 @@
// @flow // @flow
import React from 'react'; import * as React from 'react';
import * as ICONS from 'constants/icons';
import SettingsRow from 'component/settingsRow'; import SettingsRow from 'component/settingsRow';
import { FormField } from 'component/common/form'; import { FormField } from 'component/common/form';
import useBrowserNotifications from '$web/component/browserNotificationSettings/use-browser-notifications'; import useBrowserNotifications from '$web/component/browserNotificationSettings/use-browser-notifications';
import 'scss/component/notifications-blocked.scss'; import { BrowserNotificationHints, BrowserNotificationsBlocked } from '$web/component/browserNotificationHints';
import Icon from 'component/common/icon';
const BrowserNotificationsBlocked = () => {
return (
<div className="notificationsBlocked">
<Icon className="notificationsBlocked__icon" color="#E50054" icon={ICONS.ALERT} size={32} />
<div>
<span>{__('Heads up: browser notifications are currently blocked in this browser.')}</span>
<span className={'notificationsBlocked__subText'}>
{__('To enable push notifications please configure your browser to allow notifications on odysee.com.')}
</span>
</div>
</div>
);
};
const BrowserNotificationSettings = () => { const BrowserNotificationSettings = () => {
const { pushSupported, pushEnabled, pushPermission, pushToggle } = useBrowserNotifications(); const { pushSupported, pushEnabled, pushPermission, pushToggle, pushErrorModal } = useBrowserNotifications();
if (!pushSupported) return null; const pushBlocked = pushPermission === 'denied';
if (pushPermission === 'denied') return <BrowserNotificationsBlocked />;
const renderHints = () => (!pushSupported ? <BrowserNotificationHints /> : null);
const renderBlocked = () => (pushBlocked ? <BrowserNotificationsBlocked /> : null);
return ( return (
<>
<SettingsRow <SettingsRow
title={__('Browser Notifications')} title={__('Browser Notifications')}
subtitle={__("Receive push notifications in this browser, even when you're not on odysee.com")} subtitle={__("Receive push notifications in this browser, even when you're not on odysee.com")}
disabled={!pushSupported || pushBlocked}
> >
<FormField type="checkbox" name="browserNotification" onChange={pushToggle} checked={pushEnabled} /> <FormField
type="checkbox"
name="browserNotification"
disabled={!pushSupported || pushBlocked}
onChange={pushToggle}
checked={pushEnabled}
/>
</SettingsRow> </SettingsRow>
{renderHints()}
{renderBlocked()}
{pushErrorModal()}
</>
); );
}; };

View file

@ -69,3 +69,4 @@
@import '../../ui/scss/component/empty'; @import '../../ui/scss/component/empty';
@import '../../ui/scss/component/stripe-card'; @import '../../ui/scss/component/stripe-card';
@import '../../ui/scss/component/wallet-tip-send'; @import '../../ui/scss/component/wallet-tip-send';
@import '../../ui/scss/component/utils';

View file

@ -8,15 +8,45 @@ import { Lbryio } from 'lbryinc';
import { initializeApp } from 'firebase/app'; import { initializeApp } from 'firebase/app';
import { getMessaging, getToken, deleteToken } from 'firebase/messaging'; import { getMessaging, getToken, deleteToken } from 'firebase/messaging';
import { firebaseConfig, vapidKey } from '$web/src/firebase-config'; import { firebaseConfig, vapidKey } from '$web/src/firebase-config';
import { addRegistration, removeRegistration, hasRegistration } from '$web/src/fcm-management'; import { addRegistration, removeRegistration, hasRegistration } from '$web/src/push-notifications/fcm-management';
import { browserData } from '$web/src/ua'; import { browserData } from '$web/src/ua';
import { isPushSupported } from '$web/src/push-notifications/push-supported';
let messaging = null; let messaging = null;
let pushSystem = null;
if ('serviceWorker' in navigator) { (async () => {
const supported = await isPushSupported();
if (supported) {
const app = initializeApp(firebaseConfig); const app = initializeApp(firebaseConfig);
messaging = getMessaging(app); messaging = getMessaging(app);
} pushSystem = {
supported: true,
subscribe,
unsubscribe,
subscribed,
reconnect,
disconnect,
validate,
};
}
})();
// Proxy will forward to push system if it's supported.
// $FlowIssue[incompatible-type]
export default new Proxy(
{},
{
get(target, prop) {
if (pushSystem) {
return pushSystem[prop];
} else {
if (prop === 'supported') return false;
throw new Error('Push notifications are not supported in this browser environment.');
}
},
}
);
const subscriptionMetaData = () => { const subscriptionMetaData = () => {
const isMobile = window.navigator.userAgentData?.mobile || false; const isMobile = window.navigator.userAgentData?.mobile || false;
@ -31,7 +61,7 @@ const getFcmToken = async (): Promise<string | void> => {
return getToken(messaging, { serviceWorkerRegistration: swRegistration, vapidKey }); return getToken(messaging, { serviceWorkerRegistration: swRegistration, vapidKey });
}; };
export const pushSubscribe = async (userId: number, permanent: boolean = true): Promise<boolean> => { const subscribe = async (userId: number, permanent: boolean = true): Promise<boolean> => {
try { try {
const fcmToken = await getFcmToken(); const fcmToken = await getFcmToken();
if (!fcmToken) return false; if (!fcmToken) return false;
@ -43,7 +73,7 @@ export const pushSubscribe = async (userId: number, permanent: boolean = true):
} }
}; };
export const pushUnsubscribe = async (userId: number, permanent: boolean = true): Promise<boolean> => { const unsubscribe = async (userId: number, permanent: boolean = true): Promise<boolean> => {
try { try {
const fcmToken = await getFcmToken(); const fcmToken = await getFcmToken();
if (!fcmToken) return false; if (!fcmToken) return false;
@ -56,7 +86,7 @@ export const pushUnsubscribe = async (userId: number, permanent: boolean = true)
} }
}; };
export const pushIsSubscribed = async (userId: number): Promise<boolean> => { const subscribed = async (userId: number): Promise<boolean> => {
const swRegistration = await navigator.serviceWorker?.ready; const swRegistration = await navigator.serviceWorker?.ready;
if (!swRegistration || !swRegistration.pushManager) return false; if (!swRegistration || !swRegistration.pushManager) return false;
const browserSubscriptionExists = (await swRegistration.pushManager.getSubscription()) !== null; const browserSubscriptionExists = (await swRegistration.pushManager.getSubscription()) !== null;
@ -64,17 +94,17 @@ export const pushIsSubscribed = async (userId: number): Promise<boolean> => {
return browserSubscriptionExists && userRecordExists; return browserSubscriptionExists && userRecordExists;
}; };
export const pushReconnect = async (userId: number): Promise<boolean> => { const reconnect = async (userId: number): Promise<boolean> => {
if (hasRegistration(userId)) return pushSubscribe(userId, false); if (hasRegistration(userId)) return subscribe(userId, false);
return false; return false;
}; };
export const pushDisconnect = async (userId: number): Promise<boolean> => { const disconnect = async (userId: number): Promise<boolean> => {
if (hasRegistration(userId)) return pushUnsubscribe(userId, false); if (hasRegistration(userId)) return unsubscribe(userId, false);
return false; return false;
}; };
export const pushValidate = async (userId: number) => { const validate = async (userId: number) => {
if (!hasRegistration(userId)) return; if (!hasRegistration(userId)) return;
window.requestIdleCallback(async () => { window.requestIdleCallback(async () => {
const serverTokens = await Lbryio.call('cfm', 'list'); const serverTokens = await Lbryio.call('cfm', 'list');
@ -82,7 +112,7 @@ export const pushValidate = async (userId: number) => {
if (!fcmToken) return; if (!fcmToken) return;
const exists = serverTokens.find((item) => item.value === fcmToken); const exists = serverTokens.find((item) => item.value === fcmToken);
if (!exists) { if (!exists) {
await pushSubscribe(userId, false); await subscribe(userId, false);
} }
}); });
}; };

View file

@ -0,0 +1,16 @@
// @flow
import { isSupported } from 'firebase/messaging';
export const isPushSupported = async (): Promise<boolean> => {
if ('serviceWorker' in navigator) {
// Some browsers incognito expose sw but not the registration, while other don't expose sw at all.
// $FlowIssue[incompatible-type]
const activeRegistrations: Array<ServiceWorkerRegistration> = await navigator.serviceWorker.getRegistrations();
const swRegistered = activeRegistrations.length > 0;
const firebaseSupported = await isSupported();
const notificationFeature = 'Notification' in window;
return swRegistered && firebaseSupported && notificationFeature;
}
return false;
};