Notifications: Request permission after user gesture and better settings (#3155)

This commit is contained in:
Alexander Zinchuk 2023-05-28 14:32:12 +02:00
parent 227f706a0c
commit 9d4a62e839
4 changed files with 88 additions and 35 deletions

View File

@ -6,7 +6,11 @@ import { getActions, withGlobal } from '../../../global';
import useLang from '../../../hooks/useLang'; import useLang from '../../../hooks/useLang';
import useHistoryBack from '../../../hooks/useHistoryBack'; import useHistoryBack from '../../../hooks/useHistoryBack';
import { playNotifySound } from '../../../util/notifications'; import {
playNotifySound,
checkIfNotificationsSupported,
checkIfOfflinePushFailed,
} from '../../../util/notifications';
import Checkbox from '../../ui/Checkbox'; import Checkbox from '../../ui/Checkbox';
import RangeSlider from '../../ui/RangeSlider'; import RangeSlider from '../../ui/RangeSlider';
@ -56,6 +60,9 @@ const SettingsNotifications: FC<OwnProps & StateProps> = ({
const runDebounced = useRunDebounced(500, true); const runDebounced = useRunDebounced(500, true);
const areNotificationsSupported = checkIfNotificationsSupported();
const areOfflineNotificationsSupported = areNotificationsSupported && !checkIfOfflinePushFailed();
const handleSettingsChange = useCallback(( const handleSettingsChange = useCallback((
e: ChangeEvent<HTMLInputElement>, e: ChangeEvent<HTMLInputElement>,
peerType: 'contact' | 'group' | 'broadcast', peerType: 'contact' | 'group' | 'broadcast',
@ -81,8 +88,10 @@ const SettingsNotifications: FC<OwnProps & StateProps> = ({
]); ]);
const handleWebNotificationsChange = useCallback((e: ChangeEvent<HTMLInputElement>) => { const handleWebNotificationsChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
const isEnabled = e.target.checked;
updateWebNotificationSettings({ updateWebNotificationSettings({
hasWebNotifications: e.target.checked, hasWebNotifications: isEnabled,
...(!isEnabled && { hasPushNotifications: false }),
}); });
}, [updateWebNotificationSettings]); }, [updateWebNotificationSettings]);
@ -147,13 +156,14 @@ const SettingsNotifications: FC<OwnProps & StateProps> = ({
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
subLabel={lang(hasWebNotifications ? 'UserInfo.NotificationsEnabled' : 'UserInfo.NotificationsDisabled')} subLabel={lang(hasWebNotifications ? 'UserInfo.NotificationsEnabled' : 'UserInfo.NotificationsDisabled')}
checked={hasWebNotifications} checked={hasWebNotifications}
disabled={!areNotificationsSupported}
onChange={handleWebNotificationsChange} onChange={handleWebNotificationsChange}
/> />
<Checkbox <Checkbox
label="Offline notifications" label="Offline notifications"
disabled={!hasWebNotifications} disabled={!hasWebNotifications || !areOfflineNotificationsSupported}
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
subLabel={lang(hasPushNotifications ? 'UserInfo.NotificationsEnabled' : 'UserInfo.NotificationsDisabled')} subLabel={areOfflineNotificationsSupported ? lang(hasPushNotifications ? 'UserInfo.NotificationsEnabled' : 'UserInfo.NotificationsDisabled') : 'Not supported'}
checked={hasPushNotifications} checked={hasPushNotifications}
onChange={handlePushNotificationsChange} onChange={handlePushNotificationsChange}
/> />
@ -162,6 +172,7 @@ const SettingsNotifications: FC<OwnProps & StateProps> = ({
label="Sound" label="Sound"
min={0} min={0}
max={10} max={10}
disabled={!areNotificationsSupported}
value={notificationSoundVolume} value={notificationSoundVolume}
onChange={handleVolumeChange} onChange={handleVolumeChange}
/> />

View File

@ -13,7 +13,7 @@ import {
import { APP_CONFIG_REFETCH_INTERVAL, COUNTRIES_WITH_12H_TIME_FORMAT } from '../../../config'; import { APP_CONFIG_REFETCH_INTERVAL, COUNTRIES_WITH_12H_TIME_FORMAT } from '../../../config';
import { callApi } from '../../../api/gramjs'; import { callApi } from '../../../api/gramjs';
import { buildCollectionByKey } from '../../../util/iteratees'; import { buildCollectionByKey } from '../../../util/iteratees';
import { subscribe, unsubscribe } from '../../../util/notifications'; import { subscribe, unsubscribe, requestPermission } from '../../../util/notifications';
import { setTimeFormat } from '../../../util/langProvider'; import { setTimeFormat } from '../../../util/langProvider';
import requestActionTimeout from '../../../util/requestActionTimeout'; import requestActionTimeout from '../../../util/requestActionTimeout';
import { getServerTime } from '../../../util/serverTime'; import { getServerTime } from '../../../util/serverTime';
@ -353,15 +353,24 @@ addActionHandler('updateNotificationSettings', async (global, actions, payload):
setGlobal(global); setGlobal(global);
}); });
addActionHandler('updateWebNotificationSettings', (global, actions, payload): ActionReturnType => { addActionHandler('updateWebNotificationSettings', async (global, actions, payload): Promise<void> => {
const oldSettings = global.settings.byKey;
global = replaceSettings(global, payload); global = replaceSettings(global, payload);
setGlobal(global); setGlobal(global);
const { hasWebNotifications, hasPushNotifications } = global.settings.byKey;
const { hasPushNotifications, hasWebNotifications } = global.settings.byKey; if (!oldSettings.hasPushNotifications && hasPushNotifications) {
if (hasWebNotifications && hasPushNotifications) { await subscribe();
void subscribe(); }
} else { if (oldSettings.hasPushNotifications && !hasPushNotifications) {
void unsubscribe(); await unsubscribe();
}
if (!oldSettings.hasWebNotifications && hasWebNotifications) {
const isGranted = await requestPermission();
if (!isGranted) {
global = getGlobal();
global = replaceSettings(global, { hasWebNotifications: false });
setGlobal(global);
}
} }
}); });

View File

@ -88,7 +88,17 @@ addActionHandler('initShared', (): ActionReturnType => {
addActionHandler('initMain', (global): ActionReturnType => { addActionHandler('initMain', (global): ActionReturnType => {
const { hasWebNotifications, hasPushNotifications } = selectNotifySettings(global); const { hasWebNotifications, hasPushNotifications } = selectNotifySettings(global);
if (hasWebNotifications && hasPushNotifications) { 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 });
});
} }
}); });

View File

@ -13,7 +13,8 @@ import {
getMessageRecentReaction, getMessageRecentReaction,
getMessageSenderName, getMessageSenderName,
getMessageSummaryText, getMessageSummaryText,
getPrivateChatUserId, getUserFullName, getPrivateChatUserId,
getUserFullName,
isActionMessage, isActionMessage,
isChatChannel, isChatChannel,
selectIsChatMuted, selectIsChatMuted,
@ -28,7 +29,7 @@ import {
selectNotifySettings, selectNotifySettings,
selectUser, selectUser,
} from '../global/selectors'; } 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 { translate } from './langProvider';
import * as mediaLoader from './mediaLoader'; import * as mediaLoader from './mediaLoader';
import { debounce } from './schedulers'; import { debounce } from './schedulers';
@ -43,8 +44,7 @@ function getDeviceToken(subscription: PushSubscription) {
} }
function checkIfPushSupported() { function checkIfPushSupported() {
// Disable push notifications in Safari until VAPID keys are implemented on the server if (!IS_SERVICE_WORKER_SUPPORTED) return false;
if (!IS_SERVICE_WORKER_SUPPORTED || IS_SAFARI) return false;
if (!('showNotification' in ServiceWorkerRegistration.prototype)) { if (!('showNotification' in ServiceWorkerRegistration.prototype)) {
if (DEBUG) { if (DEBUG) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
@ -73,7 +73,7 @@ function checkIfPushSupported() {
return true; return true;
} }
function checkIfNotificationsSupported() { export function checkIfNotificationsSupported() {
// Let's check if the browser supports notifications // Let's check if the browser supports notifications
if (!('Notification' in window)) { if (!('Notification' in window)) {
if (DEBUG) { if (DEBUG) {
@ -135,22 +135,26 @@ function checkIfShouldResubscribe(subscription: PushSubscription | null) {
return Date.now() - global.push.subscribedAt > expirationTime; return Date.now() - global.push.subscribedAt > expirationTime;
} }
async function requestPermission() { export async function requestPermission() {
if (!('Notification' in window)) return; if (!('Notification' in window)) {
if (!['granted', 'denied'].includes(Notification.permission)) { return false;
await Notification.requestPermission();
} }
let permission = Notification.permission;
if (!['granted', 'denied'].includes(permission)) {
permission = await Notification.requestPermission();
}
return permission === 'granted';
} }
async function unsubscribeFromPush(subscription: PushSubscription | null) { async function unsubscribeFromPush(subscription: PushSubscription | null) {
const global = getGlobal(); const global = getGlobal();
const dispatch = getActions(); const { deleteDeviceToken } = getActions();
if (subscription) { if (subscription) {
try { try {
const deviceToken = getDeviceToken(subscription); const deviceToken = getDeviceToken(subscription);
await callApi('unregisterDevice', deviceToken); await callApi('unregisterDevice', deviceToken);
await subscription.unsubscribe(); await subscription.unsubscribe();
dispatch.deleteDeviceToken(); deleteDeviceToken();
return; return;
} catch (error) { } catch (error) {
if (DEBUG) { if (DEBUG) {
@ -161,7 +165,7 @@ async function unsubscribeFromPush(subscription: PushSubscription | null) {
} }
if (global.push) { if (global.push) {
await callApi('unregisterDevice', global.push.deviceToken); await callApi('unregisterDevice', global.push.deviceToken);
dispatch.deleteDeviceToken(); deleteDeviceToken();
} }
} }
@ -215,11 +219,23 @@ async function loadCustomEmoji(id: string) {
setGlobal(global); setGlobal(global);
} }
let isSubscriptionFailed = false;
export function checkIfOfflinePushFailed() {
return isSubscriptionFailed;
}
export async function subscribe() { export async function subscribe() {
const { setDeviceToken, updateWebNotificationSettings } = getActions();
let hasWebNotifications = false;
let hasPushNotifications = false;
if (!checkIfPushSupported()) { if (!checkIfPushSupported()) {
// Ask for notification permissions only if service worker notifications are not supported // Ask for notification permissions only if service worker notifications are not supported
// As pushManager.subscribe automatically triggers permission popup // As pushManager.subscribe automatically triggers permission popup
await requestPermission(); hasWebNotifications = await requestPermission();
updateWebNotificationSettings({
hasWebNotifications,
hasPushNotifications,
});
return; return;
} }
const serviceWorkerRegistration = await navigator.serviceWorker.ready; const serviceWorkerRegistration = await navigator.serviceWorker.ready;
@ -236,10 +252,11 @@ export async function subscribe() {
console.log('[PUSH] Received push subscription: ', deviceToken); console.log('[PUSH] Received push subscription: ', deviceToken);
} }
await callApi('registerDevice', deviceToken); await callApi('registerDevice', deviceToken);
getActions() setDeviceToken(deviceToken);
.setDeviceToken(deviceToken); hasPushNotifications = true;
hasWebNotifications = true;
} catch (error: any) { } catch (error: any) {
if (Notification.permission === 'denied' as NotificationPermission) { if (Notification.permission === 'denied') {
// The user denied the notification permission which // The user denied the notification permission which
// means we failed to subscribe and the user will need // means we failed to subscribe and the user will need
// to manually change the notification permission to // to manually change the notification permission to
@ -248,20 +265,26 @@ export async function subscribe() {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.warn('[PUSH] The user has blocked push notifications.'); console.warn('[PUSH] The user has blocked push notifications.');
} }
} else if (DEBUG) { } else {
// A problem occurred with the subscription, this can // A problem occurred with the subscription, this can
// often be down to an issue or lack of the gcm_sender_id // often be down to an issue or lack of the gcm_sender_id
// and / or gcm_user_visible_only // and / or gcm_user_visible_only
// eslint-disable-next-line no-console if (DEBUG) {
console.log('[PUSH] Unable to subscribe to push.', error); // eslint-disable-next-line no-console
console.log('[PUSH] Unable to subscribe to push.', error);
}
// Request permissions and fall back to local notifications // Request permissions and fall back to local notifications
// if pushManager.subscribe was aborted due to invalid VAPID key. // if pushManager.subscribe was aborted due to invalid VAPID key.
if (error.code === DOMException.ABORT_ERR) { if ([DOMException.ABORT_ERR, DOMException.NOT_SUPPORTED_ERR].includes(error.code)) {
await requestPermission(); isSubscriptionFailed = true;
hasWebNotifications = await requestPermission();
} }
} }
} }
updateWebNotificationSettings({
hasWebNotifications,
hasPushNotifications,
});
} }
function checkIfShouldNotify(chat: ApiChat, message: Partial<ApiMessage>) { function checkIfShouldNotify(chat: ApiChat, message: Partial<ApiMessage>) {