diff --git a/static/app-strings.json b/static/app-strings.json index 93ba7bebc..14d957b45 100644 --- a/static/app-strings.json +++ b/static/app-strings.json @@ -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--" } diff --git a/ui/component/settingsRow/view.jsx b/ui/component/settingsRow/view.jsx index a59b7e52b..4821dc309 100644 --- a/ui/component/settingsRow/view.jsx +++ b/ui/component/settingsRow/view.jsx @@ -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 (
diff --git a/ui/component/subscribeButton/view.jsx b/ui/component/subscribeButton/view.jsx index 0d4c60ef3..3a51b18ac 100644 --- a/ui/component/subscribeButton/view.jsx +++ b/ui/component/subscribeButton/view.jsx @@ -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 && ( -
) : null; diff --git a/ui/redux/actions/app.js b/ui/redux/actions/app.js index 5d4914124..14b986b63 100644 --- a/ui/redux/actions/app.js +++ b/ui/redux/actions/app.js @@ -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) diff --git a/ui/scss/component/_utils.scss b/ui/scss/component/_utils.scss new file mode 100644 index 000000000..fbd1cf7a8 --- /dev/null +++ b/ui/scss/component/_utils.scss @@ -0,0 +1,3 @@ +.opacity-40 { + opacity: 0.4; +} diff --git a/ui/scss/component/notifications-blocked.scss b/ui/scss/component/notifications-blocked.scss index df20f79bc..30396b378 100644 --- a/ui/scss/component/notifications-blocked.scss +++ b/ui/scss/component/notifications-blocked.scss @@ -15,3 +15,9 @@ color: var(--color-text-subtitle); font-size: var(--font-small); } + +.notificationsBlocked__subTextList { + li { + margin-left: var(--spacing-m); + } +} diff --git a/web/component/browserNotificationBanner/view.jsx b/web/component/browserNotificationBanner/view.jsx index 8ad6b9e3b..6590ccf0d 100644 --- a/web/component/browserNotificationBanner/view.jsx +++ b/web/component/browserNotificationBanner/view.jsx @@ -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 ( -
-
- -

- {__('Realtime push notifications straight to your browser.')} -
- {__("Don't miss another notification again.")} -

+ <> +
+
+ +

+ {__('Realtime push notifications straight to your browser.')} +
+ {__("Don't miss another notification again.")} +

+
+
+
-
-
-
+ {pushErrorModal()} + ); }; diff --git a/web/component/browserNotificationHints/index.jsx b/web/component/browserNotificationHints/index.jsx new file mode 100644 index 000000000..5fc0cf730 --- /dev/null +++ b/web/component/browserNotificationHints/index.jsx @@ -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 ( +
+ +
+ {title} + {children} +
+
+ ); +}; + +export const BrowserNotificationsBlocked = () => { + return ( + + {__('To enable push notifications please configure your browser to allow notifications on odysee.com.')} + + ); +}; + +export const BrowserNotificationHints = () => { + return ( + +
    +
  • {__("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." + )} +
  • +
  • {__('For Brave, enable google push notifications in settings.')}
  • +
  • {__('Check browser settings to see if notifications are disabled or otherwise restricted.')}
  • +
+
+ ); +}; + +type ModalProps = { + doHideModal: () => void, +}; +export const BrowserNotificationErrorModal = (props: ModalProps) => { + const { doHideModal } = props; + return ( + + + + ); +}; diff --git a/web/component/browserNotificationSettings/use-browser-notifications.js b/web/component/browserNotificationSettings/use-browser-notifications.js index e51fcb3c0..64cab9dec 100644 --- a/web/component/browserNotificationSettings/use-browser-notifications.js +++ b/web/component/browserNotificationSettings/use-browser-notifications.js @@ -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 && setEncounteredError(false)} />}; }; return { @@ -63,5 +64,6 @@ export default () => { pushPermission, pushToggle, pushRequest, + pushErrorModal, }; }; diff --git a/web/component/browserNotificationSettings/view.jsx b/web/component/browserNotificationSettings/view.jsx index 4e1482db3..85111e8a6 100644 --- a/web/component/browserNotificationSettings/view.jsx +++ b/web/component/browserNotificationSettings/view.jsx @@ -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 ( -
- -
- {__('Heads up: browser notifications are currently blocked in this browser.')} - - {__('To enable push notifications please configure your browser to allow notifications on odysee.com.')} - -
-
- ); -}; +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 ; + const pushBlocked = pushPermission === 'denied'; + + const renderHints = () => (!pushSupported ? : null); + const renderBlocked = () => (pushBlocked ? : null); return ( - - - + <> + + + + {renderHints()} + {renderBlocked()} + {pushErrorModal()} + ); }; diff --git a/web/scss/odysee.scss b/web/scss/odysee.scss index 1e2cf5ede..0e1aafa1c 100644 --- a/web/scss/odysee.scss +++ b/web/scss/odysee.scss @@ -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'; diff --git a/web/src/fcm-management.js b/web/src/push-notifications/fcm-management.js similarity index 100% rename from web/src/fcm-management.js rename to web/src/push-notifications/fcm-management.js diff --git a/web/src/push-notifications.js b/web/src/push-notifications/index.js similarity index 61% rename from web/src/push-notifications.js rename to web/src/push-notifications/index.js index d6c6c6e0a..376f66454 100644 --- a/web/src/push-notifications.js +++ b/web/src/push-notifications/index.js @@ -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 => { return getToken(messaging, { serviceWorkerRegistration: swRegistration, vapidKey }); }; -export const pushSubscribe = async (userId: number, permanent: boolean = true): Promise => { +const subscribe = async (userId: number, permanent: boolean = true): Promise => { 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 => { +const unsubscribe = async (userId: number, permanent: boolean = true): Promise => { 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 => { +const subscribed = async (userId: number): Promise => { 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 => { return browserSubscriptionExists && userRecordExists; }; -export const pushReconnect = async (userId: number): Promise => { - if (hasRegistration(userId)) return pushSubscribe(userId, false); +const reconnect = async (userId: number): Promise => { + if (hasRegistration(userId)) return subscribe(userId, false); return false; }; -export const pushDisconnect = async (userId: number): Promise => { - if (hasRegistration(userId)) return pushUnsubscribe(userId, false); +const disconnect = async (userId: number): Promise => { + 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); } }); }; diff --git a/web/src/push-notifications/push-supported.js b/web/src/push-notifications/push-supported.js new file mode 100644 index 000000000..121025bda --- /dev/null +++ b/web/src/push-notifications/push-supported.js @@ -0,0 +1,16 @@ +// @flow + +import { isSupported } from 'firebase/messaging'; + +export const isPushSupported = async (): Promise => { + 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 = await navigator.serviceWorker.getRegistrations(); + const swRegistered = activeRegistrations.length > 0; + const firebaseSupported = await isSupported(); + const notificationFeature = 'Notification' in window; + return swRegistered && firebaseSupported && notificationFeature; + } + return false; +};