From 9c4cb209c0c3cda82a1c0cedf3128838fcb9841d Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Thu, 29 Apr 2021 13:09:45 +0300 Subject: [PATCH] Push notifications: Use existing browser tab, unsubscribe on sign out (#1053) --- src/components/auth/AuthPhoneNumber.tsx | 52 ++++++------ src/modules/actions/api/initial.ts | 2 + src/modules/actions/apiUpdaters/initial.ts | 4 +- src/serviceWorker.ts | 54 +------------ src/serviceWorker/pushNotification.ts | 80 +++++++++++++++++++ ...hNotifications.ts => pushNotifications.ts} | 33 +++++--- webpack.config.js | 1 + 7 files changed, 136 insertions(+), 90 deletions(-) create mode 100644 src/serviceWorker/pushNotification.ts rename src/util/{setupPushNotifications.ts => pushNotifications.ts} (76%) diff --git a/src/components/auth/AuthPhoneNumber.tsx b/src/components/auth/AuthPhoneNumber.tsx index 8662f295e..b946a6cab 100644 --- a/src/components/auth/AuthPhoneNumber.tsx +++ b/src/components/auth/AuthPhoneNumber.tsx @@ -1,34 +1,34 @@ import { ChangeEvent } from 'react'; -import React, { - FC, useState, useEffect, useCallback, useLayoutEffect, useRef, memo, -} from '../../lib/teact/teact'; -import { withGlobal } from '../../lib/teact/teactn'; - -import { GlobalState, GlobalActions } from '../../global/types'; - -import { - MEDIA_CACHE_NAME, - MEDIA_CACHE_NAME_AVATARS, - MEDIA_PROGRESSIVE_CACHE_NAME, - CUSTOM_BG_CACHE_NAME, - LANG_CACHE_NAME, -} from '../../config'; -import { IS_TOUCH_ENV } from '../../util/environment'; -import * as cacheApi from '../../util/cacheApi'; -import { formatPhoneNumber, getCountryFromPhoneNumber, getCountryById } from '../../util/phoneNumber'; -import preloadFonts from '../../util/fonts'; -import { preloadImage } from '../../util/files'; -import { pick } from '../../util/iteratees'; - -import Button from '../ui/Button'; -import InputText from '../ui/InputText'; -import CountryCodeInput from './CountryCodeInput'; -import Checkbox from '../ui/Checkbox'; -import Loading from '../ui/Loading'; // @ts-ignore import monkeyPath from '../../assets/monkey.svg'; +import { + CUSTOM_BG_CACHE_NAME, + LANG_CACHE_NAME, + MEDIA_CACHE_NAME, + MEDIA_CACHE_NAME_AVATARS, + MEDIA_PROGRESSIVE_CACHE_NAME, +} from '../../config'; + +import { GlobalActions, GlobalState } from '../../global/types'; +import React, { + FC, memo, useCallback, useEffect, useLayoutEffect, useRef, useState, +} from '../../lib/teact/teact'; +import { withGlobal } from '../../lib/teact/teactn'; +import * as cacheApi from '../../util/cacheApi'; +import { IS_TOUCH_ENV } from '../../util/environment'; +import { preloadImage } from '../../util/files'; +import preloadFonts from '../../util/fonts'; +import { pick } from '../../util/iteratees'; +import { formatPhoneNumber, getCountryById, getCountryFromPhoneNumber } from '../../util/phoneNumber'; + +import Button from '../ui/Button'; +import Checkbox from '../ui/Checkbox'; +import InputText from '../ui/InputText'; +import Loading from '../ui/Loading'; +import CountryCodeInput from './CountryCodeInput'; + type StateProps = Pick { const sessionId = localStorage.getItem(GRAMJS_SESSION_ID_KEY) || undefined; @@ -101,6 +102,7 @@ addReducer('signOut', () => { }); async function signOut() { + await unsubscribeFromPush(); await callApi('destroy'); localStorage.removeItem(GRAMJS_SESSION_ID_KEY); diff --git a/src/modules/actions/apiUpdaters/initial.ts b/src/modules/actions/apiUpdaters/initial.ts index e53e9960e..ea5f43117 100644 --- a/src/modules/actions/apiUpdaters/initial.ts +++ b/src/modules/actions/apiUpdaters/initial.ts @@ -12,7 +12,7 @@ import { ApiUpdateCurrentUser, } from '../../../api/types'; import { DEBUG } from '../../../config'; -import { setupPushNotifications } from '../../../util/setupPushNotifications'; +import { subscribeToPush } from '../../../util/pushNotifications'; import { updateUser } from '../../reducers'; import { setLanguage } from '../../../util/langProvider'; @@ -57,6 +57,7 @@ addReducer('apiUpdate', (global, actions, update: ApiUpdate) => { }); function onUpdateApiReady(global: GlobalState) { + subscribeToPush(); setLanguage(global.settings.byKey.language); } @@ -139,7 +140,6 @@ function onUpdateConnectionState(update: ApiUpdateConnectionState) { if (connectionState === 'connectionStateReady' && global.authState === 'authorizationStateReady') { getDispatch().sync(); - setupPushNotifications(); } else if (connectionState === 'connectionStateBroken') { getDispatch().signOut(); } diff --git a/src/serviceWorker.ts b/src/serviceWorker.ts index 9dcc1e989..ab6a0a60d 100644 --- a/src/serviceWorker.ts +++ b/src/serviceWorker.ts @@ -1,6 +1,7 @@ import { DEBUG } from './config'; import { respondForProgressive } from './serviceWorker/progressive'; import { respondWithCache, clearAssetCache } from './serviceWorker/assetCache'; +import { handlePush, handleNotificationClick } from './serviceWorker/pushNotification'; declare const self: ServiceWorkerGlobalScope; @@ -45,54 +46,5 @@ self.addEventListener('fetch', (e: FetchEvent) => { }); -self.addEventListener('push', (e: PushEvent) => { - if (DEBUG) { - // eslint-disable-next-line no-console - console.log('[SW] Push received event', e); - if (e.data) { - // eslint-disable-next-line no-console - console.log(`[SW] Push received with data "${e.data.text()}"`); - } - } - if (!e.data) return; - let obj; - try { - obj = e.data.json(); - } catch (error) { - obj = e.data.text(); - } - - const title = obj.title || 'Telegram'; - const body = obj.description || obj; - const options = { - body, - icon: 'android-chrome-192x192.png', - }; - - e.waitUntil( - self.registration.showNotification(title, options), - ); -}); - -self.addEventListener('notificationclick', (event) => { - const url = '/'; - event.notification.close(); // Android needs explicit close. - event.waitUntil( - self.clients.matchAll({ type: 'window' }) - .then((windowClients) => { - // Check if there is already a window/tab open with the target URL - for (let i = 0; i < windowClients.length; i++) { - const client = windowClients[i] as WindowClient; - // If so, just focus it. - if (client.url === url && client.focus) { - client.focus(); - return; - } - } - // If not, then open the target URL in a new window/tab. - if (self.clients.openWindow) { - self.clients.openWindow(url); - } - }), - ); -}); +self.addEventListener('push', handlePush); +self.addEventListener('notificationclick', handleNotificationClick); diff --git a/src/serviceWorker/pushNotification.ts b/src/serviceWorker/pushNotification.ts new file mode 100644 index 000000000..b15d58a1e --- /dev/null +++ b/src/serviceWorker/pushNotification.ts @@ -0,0 +1,80 @@ +import { DEBUG } from '../config'; + +declare const self: ServiceWorkerGlobalScope; + +export enum NotificationType { + MESSAGE_TEXT = 'MESSAGE_TEXT', + MESSAGE_NOTEXT = 'MESSAGE_NOTEXT', + MESSAGE_STICKER = 'MESSAGE_STICKER' +} + +export type NotificationData = { + custom: { + msg_id: string; + from_id: string; + }; + mute: '0' | '1'; + badge: '0' | '1'; + loc_key: NotificationType; + loc_args: string[]; + random_id: number; + title: string; + description: string; +}; + + +export function handlePush(e: PushEvent) { + if (DEBUG) { + // eslint-disable-next-line no-console + console.log('[SW] Push received event', e); + if (e.data) { + // eslint-disable-next-line no-console + console.log('[SW] Push received with data', e.data.json()); + } + } + if (!e.data) return; + let data: NotificationData; + try { + data = e.data.json(); + } catch (error) { + if (DEBUG) { + // eslint-disable-next-line no-console + console.log('[SW] Unable to parse push notification data', e.data); + } + return; + } + + const title = data.title || process.env.APP_INFO!; + const body = data.description; + const options = { + body, + icon: 'android-chrome-192x192.png', + }; + + e.waitUntil( + self.registration.showNotification(title, options), + ); +} + +export function handleNotificationClick(e: NotificationEvent) { + const appUrl = process.env.APP_URL!; + e.notification.close(); // Android needs explicit close. + e.waitUntil( + self.clients.matchAll({ type: 'window' }) + .then((windowClients) => { + // Check if there is already a window/tab open with the target URL + for (let i = 0; i < windowClients.length; i++) { + const client = windowClients[i] as WindowClient; + // If so, just focus it. + if (client.url === self.registration.scope && client.focus) { + client.focus(); + return; + } + } + // If not, then open the target URL in a new window/tab. + if (self.clients.openWindow) { + self.clients.openWindow(appUrl); + } + }), + ); +} diff --git a/src/util/setupPushNotifications.ts b/src/util/pushNotifications.ts similarity index 76% rename from src/util/setupPushNotifications.ts rename to src/util/pushNotifications.ts index a352c8f27..252126e93 100644 --- a/src/util/setupPushNotifications.ts +++ b/src/util/pushNotifications.ts @@ -1,18 +1,20 @@ import { callApi } from '../api/gramjs'; import { DEBUG } from '../config'; +import { IS_SERVICE_WORKER_SUPPORTED } from './environment'; function getDeviceToken(subscription: PushSubscription) { const data = subscription.toJSON(); return JSON.stringify({ endpoint: data.endpoint, keys: data.keys }); } -export async function setupPushNotifications() { +export function isPushSupported() { + if (!IS_SERVICE_WORKER_SUPPORTED) return false; if (!('showNotification' in ServiceWorkerRegistration.prototype)) { if (DEBUG) { // eslint-disable-next-line no-console console.log('[PUSH] Push notifications aren\'t supported.'); } - return; + return false; } // Check the current Notification permission. @@ -23,7 +25,7 @@ export async function setupPushNotifications() { // eslint-disable-next-line no-console console.log('[PUSH] The user has blocked push notifications.'); } - return; + return false; } // Check if push messaging is supported @@ -32,11 +34,20 @@ export async function setupPushNotifications() { // eslint-disable-next-line no-console console.log('[PUSH] Push messaging isn\'t supported.'); } + return false; } + return true; +} +export async function unsubscribeFromPush() { + if (!isPushSupported) return; const serviceWorkerRegistration = await navigator.serviceWorker.ready; - let subscription = await serviceWorkerRegistration.pushManager.getSubscription(); + const subscription = await serviceWorkerRegistration.pushManager.getSubscription(); if (subscription) { + if (DEBUG) { + // eslint-disable-next-line no-console + console.log('[PUSH] Unsubscribing', subscription); + } try { const deviceToken = getDeviceToken(subscription); await callApi('unregisterDevice', deviceToken); @@ -48,22 +59,22 @@ export async function setupPushNotifications() { } } } +} +export async function subscribeToPush() { + if (!isPushSupported()) return; + await unsubscribeFromPush(); + const serviceWorkerRegistration = await navigator.serviceWorker.ready; try { - subscription = await serviceWorkerRegistration.pushManager.subscribe({ + const subscription = await serviceWorkerRegistration.pushManager.subscribe({ userVisibleOnly: true, }); - const deviceToken = getDeviceToken(subscription); if (DEBUG) { // eslint-disable-next-line no-console console.log('[PUSH] Received push subscription: ', deviceToken); } - const result = await callApi('registerDevice', deviceToken); - if (DEBUG) { - // eslint-disable-next-line no-console - console.log('[PUSH] registerDevice result', result); - } + await callApi('registerDevice', deviceToken); } catch (error) { if (Notification.permission === 'denied' as NotificationPermission) { // The user denied the notification permission which diff --git a/webpack.config.js b/webpack.config.js index d34f2a28e..151013a81 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -105,6 +105,7 @@ module.exports = (env = {}, argv = {}) => { new EnvironmentPlugin({ APP_INFO: 'Telegram T', APP_ENV: 'production', + APP_URL: 'https://webz.telegram.org/', TELEGRAM_T_API_ID: '', TELEGRAM_T_API_HASH: '', }),