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",
"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.",
"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--"
}

View file

@ -7,16 +7,17 @@ type Props = {
subtitle?: string,
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.
disabled?: boolean,
children?: React$Node,
};
export default function SettingsRow(props: Props) {
const { title, subtitle, multirow, useVerticalSeparator, children } = props;
const { title, subtitle, multirow, useVerticalSeparator, disabled, children } = props;
return (
<div
className={classnames('card__main-actions settings__row', {
'section__actions--between': !multirow,
'opacity-40': disabled,
})}
>
<div className="settings__row--title">

View file

@ -59,7 +59,7 @@ export default function SubscribeButton(props: Props) {
}
const claimName = channelName && '@' + channelName;
const { pushSupported, pushEnabled, pushRequest } = useBrowserNotifications();
const { pushSupported, pushEnabled, pushRequest, pushErrorModal } = useBrowserNotifications();
const subscriptionHandler = isSubscribed ? doChannelUnsubscribe : doChannelSubscribe;
@ -125,36 +125,39 @@ export default function SubscribeButton(props: Props) {
}}
/>
{isSubscribed && uiNotificationsEnabled && (
<Button
button="alt"
icon={notificationsDisabled ? ICONS.BELL : ICONS.BELL_ON}
aria-label={notificationsDisabled ? __('Turn on notifications') : __('Turn off notifications')}
onClick={() => {
const newNotificationsDisabled = !notificationsDisabled;
<>
<Button
button="alt"
icon={notificationsDisabled ? ICONS.BELL : ICONS.BELL_ON}
aria-label={notificationsDisabled ? __('Turn on notifications') : __('Turn off notifications')}
onClick={() => {
const newNotificationsDisabled = !notificationsDisabled;
doChannelSubscribe(
{
channelName: claimName,
uri: permanentUrl,
notificationsDisabled: newNotificationsDisabled,
},
false
);
doChannelSubscribe(
{
channelName: claimName,
uri: permanentUrl,
notificationsDisabled: newNotificationsDisabled,
},
false
);
doToast({
message: __(
newNotificationsDisabled
? 'Notifications turned off for %channel%'
: 'Notifications turned on for %channel%!',
{ channel: claimName }
),
});
doToast({
message: __(
newNotificationsDisabled
? 'Notifications turned off for %channel%'
: 'Notifications turned on for %channel%!',
{ channel: claimName }
),
});
if (!newNotificationsDisabled && pushSupported && !pushEnabled) {
pushRequest();
}
}}
/>
if (!newNotificationsDisabled && pushSupported && !pushEnabled) {
pushRequest();
}
}}
/>
{pushErrorModal()}
</>
)}
</div>
) : null;

View file

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

View file

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

View file

@ -15,3 +15,9 @@
color: var(--color-text-subtitle);
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';
export const BrowserNotificationBanner = () => {
const { pushSupported, pushEnabled, pushPermission, pushToggle } = useBrowserNotifications();
const { pushSupported, pushEnabled, pushPermission, pushToggle, pushErrorModal } = useBrowserNotifications();
const [hasAcknowledgedPush, setHasAcknowledgedPush] = usePersistedState('push-nag', false);
if (!pushSupported || pushEnabled || pushPermission === 'denied' || hasAcknowledgedPush) return null;
@ -16,26 +16,29 @@ export const BrowserNotificationBanner = () => {
const handleClose = () => setHasAcknowledgedPush(true);
return (
<div className="browserNotificationsBanner notice-message">
<div className="browserNotificationsBanner__overview">
<Icon className="browserNotificationsBanner__icon" icon={ICONS.NOTIFICATION} size={32} />
<p>
<strong>{__('Realtime push notifications straight to your browser.')}</strong>
<br />
<span className="notificationsBlocked__subText">{__("Don't miss another notification again.")}</span>
</p>
<>
<div className="browserNotificationsBanner notice-message">
<div className="browserNotificationsBanner__overview">
<Icon className="browserNotificationsBanner__icon" icon={ICONS.NOTIFICATION} size={32} />
<p>
<strong>{__('Realtime push notifications straight to your browser.')}</strong>
<br />
<span className="notificationsBlocked__subText">{__("Don't miss another notification again.")}</span>
</p>
</div>
<div className="browserNotificationsBanner__actions">
<Button
className="browserNotificationsBanner__button"
button="primary"
title={__('Enable Push Notifications')}
label={__('Enable Push Notifications')}
onClick={pushToggle}
/>
<Button button="close" title={__('Dismiss')} icon={ICONS.REMOVE} onClick={handleClose} />
</div>
</div>
<div className="browserNotificationsBanner__actions">
<Button
className="browserNotificationsBanner__button"
button="primary"
title={__('Enable Push Notifications')}
label={__('Enable Push Notifications')}
onClick={pushToggle}
/>
<Button button="close" title={__('Dismiss')} icon={ICONS.REMOVE} onClick={handleClose} />
</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
import { useEffect, useState, useMemo } from 'react';
import { pushSubscribe, pushUnsubscribe, pushIsSubscribed } from '$web/src/push-notifications';
import { isSupported } from 'firebase/messaging';
import React, { useEffect, useState, useMemo } from 'react';
import pushNotifications from '$web/src/push-notifications';
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.
import { store } from '$ui/store';
import { selectUser } from 'redux/selectors/user';
import { doToast } from 'redux/actions/notifications';
export default () => {
const [pushPermission, setPushPermission] = useState(window.Notification?.permission);
const [subscribed, setSubscribed] = useState(false);
const [pushEnabled, setPushEnabled] = useState(false);
const [pushSupported, setPushSupported] = useState(false);
const [encounteredError, setEncounteredError] = useState(false);
const [user] = useState(selectUser(store.getState()));
useEffect(() => {
pushIsSubscribed(user.id).then((isSubscribed: boolean) => setSubscribed(isSubscribed));
isSupported().then((supported: boolean) => setPushSupported(supported));
setPushSupported(pushNotifications.supported);
if (pushNotifications.supported) {
pushNotifications.subscribed(user.id).then((isSubscribed: boolean) => setSubscribed(isSubscribed));
}
}, [user]);
useMemo(() => setPushEnabled(pushPermission === 'granted' && subscribed), [pushPermission, subscribed]);
const subscribe = async () => {
if (await pushSubscribe(user.id)) {
setSubscribed(true);
setPushPermission(window.Notification?.permission);
} else {
showError();
setEncounteredError(false);
try {
if (await pushNotifications.subscribe(user.id)) {
setSubscribed(true);
setPushPermission(window.Notification?.permission);
return true;
} else {
setEncounteredError(true);
}
} catch {
setEncounteredError(true);
}
};
const unsubscribe = async () => {
if (await pushUnsubscribe(user.id)) {
if (await pushNotifications.unsubscribe(user.id)) {
setSubscribed(false);
}
};
@ -46,15 +54,8 @@ export default () => {
return window.Notification?.permission !== 'granted' ? subscribe() : null;
};
const showError = () => {
store.dispatch(
doToast({
isError: true,
message: __(
'There was an error enabling browser notifications. Please make sure your browser settings allow you to subscribe to notifications.'
),
})
);
const pushErrorModal = () => {
return <>{encounteredError && <BrowserNotificationErrorModal doHideModal={() => setEncounteredError(false)} />}</>;
};
return {
@ -63,5 +64,6 @@ export default () => {
pushPermission,
pushToggle,
pushRequest,
pushErrorModal,
};
};

View file

@ -1,39 +1,37 @@
// @flow
import React from 'react';
import * as ICONS from 'constants/icons';
import * as React from 'react';
import SettingsRow from 'component/settingsRow';
import { FormField } from 'component/common/form';
import useBrowserNotifications from '$web/component/browserNotificationSettings/use-browser-notifications';
import 'scss/component/notifications-blocked.scss';
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>
);
};
import { BrowserNotificationHints, BrowserNotificationsBlocked } from '$web/component/browserNotificationHints';
const BrowserNotificationSettings = () => {
const { pushSupported, pushEnabled, pushPermission, pushToggle } = useBrowserNotifications();
const { pushSupported, pushEnabled, pushPermission, pushToggle, pushErrorModal } = useBrowserNotifications();
if (!pushSupported) return null;
if (pushPermission === 'denied') return <BrowserNotificationsBlocked />;
const pushBlocked = pushPermission === 'denied';
const renderHints = () => (!pushSupported ? <BrowserNotificationHints /> : null);
const renderBlocked = () => (pushBlocked ? <BrowserNotificationsBlocked /> : null);
return (
<SettingsRow
title={__('Browser Notifications')}
subtitle={__("Receive push notifications in this browser, even when you're not on odysee.com")}
>
<FormField type="checkbox" name="browserNotification" onChange={pushToggle} checked={pushEnabled} />
</SettingsRow>
<>
<SettingsRow
title={__('Browser Notifications')}
subtitle={__("Receive push notifications in this browser, even when you're not on odysee.com")}
disabled={!pushSupported || pushBlocked}
>
<FormField
type="checkbox"
name="browserNotification"
disabled={!pushSupported || pushBlocked}
onChange={pushToggle}
checked={pushEnabled}
/>
</SettingsRow>
{renderHints()}
{renderBlocked()}
{pushErrorModal()}
</>
);
};

View file

@ -69,3 +69,4 @@
@import '../../ui/scss/component/empty';
@import '../../ui/scss/component/stripe-card';
@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 { getMessaging, getToken, deleteToken } from 'firebase/messaging';
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 { isPushSupported } from '$web/src/push-notifications/push-supported';
let messaging = null;
let pushSystem = null;
if ('serviceWorker' in navigator) {
const app = initializeApp(firebaseConfig);
messaging = getMessaging(app);
}
(async () => {
const supported = await isPushSupported();
if (supported) {
const app = initializeApp(firebaseConfig);
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 isMobile = window.navigator.userAgentData?.mobile || false;
@ -31,7 +61,7 @@ const getFcmToken = async (): Promise<string | void> => {
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 {
const fcmToken = await getFcmToken();
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 {
const fcmToken = await getFcmToken();
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;
if (!swRegistration || !swRegistration.pushManager) return false;
const browserSubscriptionExists = (await swRegistration.pushManager.getSubscription()) !== null;
@ -64,17 +94,17 @@ export const pushIsSubscribed = async (userId: number): Promise<boolean> => {
return browserSubscriptionExists && userRecordExists;
};
export const pushReconnect = async (userId: number): Promise<boolean> => {
if (hasRegistration(userId)) return pushSubscribe(userId, false);
const reconnect = async (userId: number): Promise<boolean> => {
if (hasRegistration(userId)) return subscribe(userId, false);
return false;
};
export const pushDisconnect = async (userId: number): Promise<boolean> => {
if (hasRegistration(userId)) return pushUnsubscribe(userId, false);
const disconnect = async (userId: number): Promise<boolean> => {
if (hasRegistration(userId)) return unsubscribe(userId, false);
return false;
};
export const pushValidate = async (userId: number) => {
const validate = async (userId: number) => {
if (!hasRegistration(userId)) return;
window.requestIdleCallback(async () => {
const serverTokens = await Lbryio.call('cfm', 'list');
@ -82,7 +112,7 @@ export const pushValidate = async (userId: number) => {
if (!fcmToken) return;
const exists = serverTokens.find((item) => item.value === fcmToken);
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;
};