From 9d4a62e839e51768d4ed9c609973c901b5e03ce4 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Sun, 28 May 2023 14:32:12 +0200 Subject: [PATCH] Notifications: Request permission after user gesture and better settings (#3155) --- .../left/settings/SettingsNotifications.tsx | 19 ++++-- src/global/actions/api/settings.ts | 25 ++++--- src/global/actions/ui/initial.ts | 12 +++- src/util/notifications.ts | 67 +++++++++++++------ 4 files changed, 88 insertions(+), 35 deletions(-) diff --git a/src/components/left/settings/SettingsNotifications.tsx b/src/components/left/settings/SettingsNotifications.tsx index cf5b5fd5b..f872948b9 100644 --- a/src/components/left/settings/SettingsNotifications.tsx +++ b/src/components/left/settings/SettingsNotifications.tsx @@ -6,7 +6,11 @@ import { getActions, withGlobal } from '../../../global'; import useLang from '../../../hooks/useLang'; import useHistoryBack from '../../../hooks/useHistoryBack'; -import { playNotifySound } from '../../../util/notifications'; +import { + playNotifySound, + checkIfNotificationsSupported, + checkIfOfflinePushFailed, +} from '../../../util/notifications'; import Checkbox from '../../ui/Checkbox'; import RangeSlider from '../../ui/RangeSlider'; @@ -56,6 +60,9 @@ const SettingsNotifications: FC = ({ const runDebounced = useRunDebounced(500, true); + const areNotificationsSupported = checkIfNotificationsSupported(); + const areOfflineNotificationsSupported = areNotificationsSupported && !checkIfOfflinePushFailed(); + const handleSettingsChange = useCallback(( e: ChangeEvent, peerType: 'contact' | 'group' | 'broadcast', @@ -81,8 +88,10 @@ const SettingsNotifications: FC = ({ ]); const handleWebNotificationsChange = useCallback((e: ChangeEvent) => { + const isEnabled = e.target.checked; updateWebNotificationSettings({ - hasWebNotifications: e.target.checked, + hasWebNotifications: isEnabled, + ...(!isEnabled && { hasPushNotifications: false }), }); }, [updateWebNotificationSettings]); @@ -147,13 +156,14 @@ const SettingsNotifications: FC = ({ // eslint-disable-next-line max-len subLabel={lang(hasWebNotifications ? 'UserInfo.NotificationsEnabled' : 'UserInfo.NotificationsDisabled')} checked={hasWebNotifications} + disabled={!areNotificationsSupported} onChange={handleWebNotificationsChange} /> @@ -162,6 +172,7 @@ const SettingsNotifications: FC = ({ label="Sound" min={0} max={10} + disabled={!areNotificationsSupported} value={notificationSoundVolume} onChange={handleVolumeChange} /> diff --git a/src/global/actions/api/settings.ts b/src/global/actions/api/settings.ts index 834712997..7dec00278 100644 --- a/src/global/actions/api/settings.ts +++ b/src/global/actions/api/settings.ts @@ -13,7 +13,7 @@ import { import { APP_CONFIG_REFETCH_INTERVAL, COUNTRIES_WITH_12H_TIME_FORMAT } from '../../../config'; import { callApi } from '../../../api/gramjs'; import { buildCollectionByKey } from '../../../util/iteratees'; -import { subscribe, unsubscribe } from '../../../util/notifications'; +import { subscribe, unsubscribe, requestPermission } from '../../../util/notifications'; import { setTimeFormat } from '../../../util/langProvider'; import requestActionTimeout from '../../../util/requestActionTimeout'; import { getServerTime } from '../../../util/serverTime'; @@ -353,15 +353,24 @@ addActionHandler('updateNotificationSettings', async (global, actions, payload): setGlobal(global); }); -addActionHandler('updateWebNotificationSettings', (global, actions, payload): ActionReturnType => { +addActionHandler('updateWebNotificationSettings', async (global, actions, payload): Promise => { + const oldSettings = global.settings.byKey; global = replaceSettings(global, payload); setGlobal(global); - - const { hasPushNotifications, hasWebNotifications } = global.settings.byKey; - if (hasWebNotifications && hasPushNotifications) { - void subscribe(); - } else { - void unsubscribe(); + const { hasWebNotifications, hasPushNotifications } = global.settings.byKey; + if (!oldSettings.hasPushNotifications && hasPushNotifications) { + await subscribe(); + } + if (oldSettings.hasPushNotifications && !hasPushNotifications) { + await unsubscribe(); + } + if (!oldSettings.hasWebNotifications && hasWebNotifications) { + const isGranted = await requestPermission(); + if (!isGranted) { + global = getGlobal(); + global = replaceSettings(global, { hasWebNotifications: false }); + setGlobal(global); + } } }); diff --git a/src/global/actions/ui/initial.ts b/src/global/actions/ui/initial.ts index 6447d8e0b..3dec769ce 100644 --- a/src/global/actions/ui/initial.ts +++ b/src/global/actions/ui/initial.ts @@ -88,7 +88,17 @@ addActionHandler('initShared', (): ActionReturnType => { addActionHandler('initMain', (global): ActionReturnType => { const { hasWebNotifications, hasPushNotifications } = selectNotifySettings(global); if (hasWebNotifications && hasPushNotifications) { - void subscribe(); + // Most of the browsers only show the notifications permission prompt after the first user gesture. + const events = ['click', 'keypress']; + const subscribeAfterUserGesture = () => { + void subscribe(); + events.forEach((event) => { + document.removeEventListener(event, subscribeAfterUserGesture); + }); + }; + events.forEach((event) => { + document.addEventListener(event, subscribeAfterUserGesture, { once: true }); + }); } }); diff --git a/src/util/notifications.ts b/src/util/notifications.ts index 8a6b2bd23..86285b55e 100644 --- a/src/util/notifications.ts +++ b/src/util/notifications.ts @@ -13,7 +13,8 @@ import { getMessageRecentReaction, getMessageSenderName, getMessageSummaryText, - getPrivateChatUserId, getUserFullName, + getPrivateChatUserId, + getUserFullName, isActionMessage, isChatChannel, selectIsChatMuted, @@ -28,7 +29,7 @@ import { selectNotifySettings, selectUser, } from '../global/selectors'; -import { IS_SERVICE_WORKER_SUPPORTED, IS_TOUCH_ENV, IS_SAFARI } from './windowEnvironment'; +import { IS_SERVICE_WORKER_SUPPORTED, IS_TOUCH_ENV } from './windowEnvironment'; import { translate } from './langProvider'; import * as mediaLoader from './mediaLoader'; import { debounce } from './schedulers'; @@ -43,8 +44,7 @@ function getDeviceToken(subscription: PushSubscription) { } function checkIfPushSupported() { - // Disable push notifications in Safari until VAPID keys are implemented on the server - if (!IS_SERVICE_WORKER_SUPPORTED || IS_SAFARI) return false; + if (!IS_SERVICE_WORKER_SUPPORTED) return false; if (!('showNotification' in ServiceWorkerRegistration.prototype)) { if (DEBUG) { // eslint-disable-next-line no-console @@ -73,7 +73,7 @@ function checkIfPushSupported() { return true; } -function checkIfNotificationsSupported() { +export function checkIfNotificationsSupported() { // Let's check if the browser supports notifications if (!('Notification' in window)) { if (DEBUG) { @@ -135,22 +135,26 @@ function checkIfShouldResubscribe(subscription: PushSubscription | null) { return Date.now() - global.push.subscribedAt > expirationTime; } -async function requestPermission() { - if (!('Notification' in window)) return; - if (!['granted', 'denied'].includes(Notification.permission)) { - await Notification.requestPermission(); +export async function requestPermission() { + if (!('Notification' in window)) { + return false; } + let permission = Notification.permission; + if (!['granted', 'denied'].includes(permission)) { + permission = await Notification.requestPermission(); + } + return permission === 'granted'; } async function unsubscribeFromPush(subscription: PushSubscription | null) { const global = getGlobal(); - const dispatch = getActions(); + const { deleteDeviceToken } = getActions(); if (subscription) { try { const deviceToken = getDeviceToken(subscription); await callApi('unregisterDevice', deviceToken); await subscription.unsubscribe(); - dispatch.deleteDeviceToken(); + deleteDeviceToken(); return; } catch (error) { if (DEBUG) { @@ -161,7 +165,7 @@ async function unsubscribeFromPush(subscription: PushSubscription | null) { } if (global.push) { await callApi('unregisterDevice', global.push.deviceToken); - dispatch.deleteDeviceToken(); + deleteDeviceToken(); } } @@ -215,11 +219,23 @@ async function loadCustomEmoji(id: string) { setGlobal(global); } +let isSubscriptionFailed = false; +export function checkIfOfflinePushFailed() { + return isSubscriptionFailed; +} + export async function subscribe() { + const { setDeviceToken, updateWebNotificationSettings } = getActions(); + let hasWebNotifications = false; + let hasPushNotifications = false; if (!checkIfPushSupported()) { // Ask for notification permissions only if service worker notifications are not supported // As pushManager.subscribe automatically triggers permission popup - await requestPermission(); + hasWebNotifications = await requestPermission(); + updateWebNotificationSettings({ + hasWebNotifications, + hasPushNotifications, + }); return; } const serviceWorkerRegistration = await navigator.serviceWorker.ready; @@ -236,10 +252,11 @@ export async function subscribe() { console.log('[PUSH] Received push subscription: ', deviceToken); } await callApi('registerDevice', deviceToken); - getActions() - .setDeviceToken(deviceToken); + setDeviceToken(deviceToken); + hasPushNotifications = true; + hasWebNotifications = true; } catch (error: any) { - if (Notification.permission === 'denied' as NotificationPermission) { + if (Notification.permission === 'denied') { // The user denied the notification permission which // means we failed to subscribe and the user will need // to manually change the notification permission to @@ -248,20 +265,26 @@ export async function subscribe() { // eslint-disable-next-line no-console console.warn('[PUSH] The user has blocked push notifications.'); } - } else if (DEBUG) { + } else { // A problem occurred with the subscription, this can // often be down to an issue or lack of the gcm_sender_id // and / or gcm_user_visible_only - // eslint-disable-next-line no-console - console.log('[PUSH] Unable to subscribe to push.', error); - + if (DEBUG) { + // eslint-disable-next-line no-console + console.log('[PUSH] Unable to subscribe to push.', error); + } // Request permissions and fall back to local notifications // if pushManager.subscribe was aborted due to invalid VAPID key. - if (error.code === DOMException.ABORT_ERR) { - await requestPermission(); + if ([DOMException.ABORT_ERR, DOMException.NOT_SUPPORTED_ERR].includes(error.code)) { + isSubscriptionFailed = true; + hasWebNotifications = await requestPermission(); } } } + updateWebNotificationSettings({ + hasWebNotifications, + hasPushNotifications, + }); } function checkIfShouldNotify(chat: ApiChat, message: Partial) {