From e0530132192858aba419b61b8e3955ec0430e639 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Sat, 1 May 2021 19:32:42 +0300 Subject: [PATCH] Push Notifications: Open chat message on notification click, various fixes (#1055) --- src/global/cache.ts | 1 + src/global/types.ts | 8 +- src/modules/actions/api/initial.ts | 17 ++++ src/modules/actions/api/sync.ts | 4 + src/serviceWorker.ts | 3 +- src/serviceWorker/pushNotification.ts | 138 ++++++++++++++++++-------- src/util/pushNotifications.ts | 53 +++++++--- src/util/setupServiceWorker.ts | 31 +++++- 8 files changed, 194 insertions(+), 61 deletions(-) diff --git a/src/global/cache.ts b/src/global/cache.ts index 02804a868..60d3e8d36 100644 --- a/src/global/cache.ts +++ b/src/global/cache.ts @@ -110,6 +110,7 @@ function updateCache() { 'chatFolders', 'topPeers', 'recentEmojis', + 'push', ]), isChatInfoShown: reduceShowChatInfo(global), users: reduceUsers(global), diff --git a/src/global/types.ts b/src/global/types.ts index a1323571a..b9cbfafc8 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -375,6 +375,11 @@ export type GlobalState = { error?: string; waitingEmailCodeLength?: number; }; + + push?: { + deviceToken: string; + subscribedAt: number; + }; }; export type ActionTypes = ( @@ -439,7 +444,8 @@ export type ActionTypes = ( 'clickInlineButton' | 'sendBotCommand' | // misc 'openMediaViewer' | 'closeMediaViewer' | 'openAudioPlayer' | 'closeAudioPlayer' | 'openPollModal' | 'closePollModal' | - 'loadWebPagePreview' | 'clearWebPagePreview' | 'loadWallpapers' | 'uploadWallpaper' | + 'loadWebPagePreview' | 'clearWebPagePreview' | 'loadWallpapers' | 'uploadWallpaper' | 'setDeviceToken' | + 'deleteDeviceToken' | // payment 'openPaymentModal' | 'closePaymentModal' | 'validateRequestedInfo' | 'setPaymentStep' | 'sendPaymentForm' | 'getPaymentForm' | 'getReceipt' | diff --git a/src/modules/actions/api/initial.ts b/src/modules/actions/api/initial.ts index dc4802188..30e2eac41 100644 --- a/src/modules/actions/api/initial.ts +++ b/src/modules/actions/api/initial.ts @@ -140,3 +140,20 @@ addReducer('loadNearestCountry', (global) => { }); })(); }); + + +addReducer('setDeviceToken', (global, actions, deviceToken) => { + setGlobal({ + ...global, + push: { + deviceToken, + subscribedAt: Date.now(), + }, + }); +}); + +addReducer('deleteDeviceToken', (global) => { + const newGlobal = { ...global }; + delete newGlobal.push; + setGlobal(newGlobal); +}); diff --git a/src/modules/actions/api/sync.ts b/src/modules/actions/api/sync.ts index ee2427088..77b8877cf 100644 --- a/src/modules/actions/api/sync.ts +++ b/src/modules/actions/api/sync.ts @@ -12,6 +12,7 @@ import { } from '../../../config'; import { callApi } from '../../../api/gramjs'; import { buildCollectionByKey } from '../../../util/iteratees'; +import { notifyClientReady } from '../../../util/pushNotifications'; import { replaceChatListIds, replaceChats, @@ -41,6 +42,9 @@ async function sync(afterSyncCallback: () => void) { console.log('>>> START SYNC'); } + // Notify web worker that client is ready to receive messages + notifyClientReady(); + await callApi('fetchCurrentUser'); // This fetches only active chats and clears archived chats, which will be fetched in `afterSync` diff --git a/src/serviceWorker.ts b/src/serviceWorker.ts index ab6a0a60d..1127f99f5 100644 --- a/src/serviceWorker.ts +++ b/src/serviceWorker.ts @@ -1,7 +1,7 @@ import { DEBUG } from './config'; import { respondForProgressive } from './serviceWorker/progressive'; import { respondWithCache, clearAssetCache } from './serviceWorker/assetCache'; -import { handlePush, handleNotificationClick } from './serviceWorker/pushNotification'; +import { handlePush, handleNotificationClick, handleClientMessage } from './serviceWorker/pushNotification'; declare const self: ServiceWorkerGlobalScope; @@ -48,3 +48,4 @@ self.addEventListener('fetch', (e: FetchEvent) => { self.addEventListener('push', handlePush); self.addEventListener('notificationclick', handleNotificationClick); +self.addEventListener('message', handleClientMessage); diff --git a/src/serviceWorker/pushNotification.ts b/src/serviceWorker/pushNotification.ts index b15d58a1e..db1de2bf9 100644 --- a/src/serviceWorker/pushNotification.ts +++ b/src/serviceWorker/pushNotification.ts @@ -2,26 +2,35 @@ 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; + msg_id?: string; + channel_id?: string; + chat_id?: string; + from_id?: string; }; mute: '0' | '1'; badge: '0' | '1'; - loc_key: NotificationType; + loc_key: string; loc_args: string[]; random_id: number; title: string; description: string; }; +const clickBuffer: Record = {}; + +function getPushData(e: PushEvent | Notification): NotificationData | undefined { + try { + return 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 undefined; + } +} export function handlePush(e: PushEvent) { if (DEBUG) { @@ -32,49 +41,92 @@ export function handlePush(e: PushEvent) { 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 data = getPushData(e); + if (!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), + self.registration.showNotification(title, { + body, + data, + icon: 'android-chrome-192x192.png', + }), ); } +function getChatId(data: NotificationData) { + if (data.custom.from_id) { + return parseInt(data.custom.from_id, 10); + } + // Chats and channels have negative IDs + if (data.custom.chat_id) { + return parseInt(data.custom.chat_id, 10) * -1; + } + if (data.custom.channel_id) { + return parseInt(data.custom.channel_id, 10) * -1; + } + return undefined; +} + +function getMessageId(data: NotificationData) { + if (!data.custom.msg_id) return undefined; + return parseInt(data.custom.msg_id, 10); +} + +function focusChatMessage(client: WindowClient, data: NotificationData) { + const chatId = getChatId(data); + const messageId = getMessageId(data); + + if (chatId) { + client.postMessage({ + type: 'focusMessage', + payload: { + chatId, + messageId, + }, + }); + } + if (client.focus) { + client.focus(); + } +} + 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); - } - }), - ); + + const { data } = e.notification; + const notifyClients = async () => { + const clients = await self.clients.matchAll({ type: 'window' }) as WindowClient[]; + const clientsInScope = clients.filter((client) => client.url === self.registration.scope); + clientsInScope.forEach((client) => focusChatMessage(client, data)); + if (!self.clients.openWindow || clientsInScope.length > 0) return undefined; + + // If there is no opened client we need to open one and wait until it is fully loaded + const newClient = await self.clients.openWindow(appUrl); + if (newClient) { + // Store notification data until client is fully loaded + clickBuffer[newClient.id] = data; + } + return undefined; + }; + e.waitUntil(notifyClients()); +} + +export function handleClientMessage(e: ExtendableMessageEvent) { + if (DEBUG) { + // eslint-disable-next-line no-console + console.log('[SW] New message from client', e); + } + if (e.data && e.data.type === 'clientReady') { + const source = e.source as WindowClient; + + // focus on chat message when client is fully ready + if (clickBuffer[source.id]) { + focusChatMessage(source, clickBuffer[source.id]); + delete clickBuffer[source.id]; + } + } } diff --git a/src/util/pushNotifications.ts b/src/util/pushNotifications.ts index 252126e93..bd852f5d0 100644 --- a/src/util/pushNotifications.ts +++ b/src/util/pushNotifications.ts @@ -1,5 +1,6 @@ import { callApi } from '../api/gramjs'; import { DEBUG } from '../config'; +import { getDispatch, getGlobal } from '../lib/teact/teactn'; import { IS_SERVICE_WORKER_SUPPORTED } from './environment'; function getDeviceToken(subscription: PushSubscription) { @@ -7,7 +8,7 @@ function getDeviceToken(subscription: PushSubscription) { return JSON.stringify({ endpoint: data.endpoint, keys: data.keys }); } -export function isPushSupported() { +function checkIfSupported() { if (!IS_SERVICE_WORKER_SUPPORTED) return false; if (!('showNotification' in ServiceWorkerRegistration.prototype)) { if (DEBUG) { @@ -39,19 +40,25 @@ export function isPushSupported() { return true; } -export async function unsubscribeFromPush() { - if (!isPushSupported) return; - const serviceWorkerRegistration = await navigator.serviceWorker.ready; - const subscription = await serviceWorkerRegistration.pushManager.getSubscription(); +const expirationTime = 12 * 60 * 60 * 1000; // 12 hours + +function checkIfShouldResubscribe(subscription: PushSubscription | null) { + const global = getGlobal(); + if (!global.push || !subscription) return true; + if (getDeviceToken(subscription) !== global.push.deviceToken) return true; + return Date.now() - global.push.subscribedAt > expirationTime; +} + +async function unsubscribe(subscription: PushSubscription | null) { + const global = getGlobal(); + const dispatch = getDispatch(); if (subscription) { - if (DEBUG) { - // eslint-disable-next-line no-console - console.log('[PUSH] Unsubscribing', subscription); - } try { const deviceToken = getDeviceToken(subscription); await callApi('unregisterDevice', deviceToken); await subscription.unsubscribe(); + dispatch.deleteDeviceToken(); + return; } catch (error) { if (DEBUG) { // eslint-disable-next-line no-console @@ -59,14 +66,27 @@ export async function unsubscribeFromPush() { } } } + if (global.push) { + await callApi('unregisterDevice', global.push.deviceToken); + dispatch.deleteDeviceToken(); + } +} + +export async function unsubscribeFromPush() { + if (!checkIfSupported()) return; + const serviceWorkerRegistration = await navigator.serviceWorker.ready; + const subscription = await serviceWorkerRegistration.pushManager.getSubscription(); + await unsubscribe(subscription); } export async function subscribeToPush() { - if (!isPushSupported()) return; - await unsubscribeFromPush(); + if (!checkIfSupported()) return; const serviceWorkerRegistration = await navigator.serviceWorker.ready; + let subscription = await serviceWorkerRegistration.pushManager.getSubscription(); + if (!checkIfShouldResubscribe(subscription)) return; + await unsubscribe(subscription); try { - const subscription = await serviceWorkerRegistration.pushManager.subscribe({ + subscription = await serviceWorkerRegistration.pushManager.subscribe({ userVisibleOnly: true, }); const deviceToken = getDeviceToken(subscription); @@ -75,6 +95,7 @@ export async function subscribeToPush() { console.log('[PUSH] Received push subscription: ', deviceToken); } await callApi('registerDevice', deviceToken); + getDispatch().setDeviceToken(deviceToken); } catch (error) { if (Notification.permission === 'denied' as NotificationPermission) { // The user denied the notification permission which @@ -94,3 +115,11 @@ export async function subscribeToPush() { } } } + +// Notify service worker that client is fully loaded +export function notifyClientReady() { + if (!navigator.serviceWorker.controller) return; + navigator.serviceWorker.controller.postMessage({ + type: 'clientReady', + }); +} diff --git a/src/util/setupServiceWorker.ts b/src/util/setupServiceWorker.ts index 70956d643..e2e9a0154 100644 --- a/src/util/setupServiceWorker.ts +++ b/src/util/setupServiceWorker.ts @@ -4,6 +4,27 @@ import { DEBUG } from '../config'; import { getDispatch } from '../lib/teact/teactn'; import { IS_SERVICE_WORKER_SUPPORTED } from './environment'; +type WorkerAction = { + type: string; + payload: Record; +}; + + +function handleWorkerMessage(e: MessageEvent) { + const action:WorkerAction = e.data; + if (DEBUG) { + // eslint-disable-next-line no-console + console.log('[SW] New action from service worker', action); + } + if (!action.type) return; + const dispatch = getDispatch(); + switch (action.type) { + case 'focusMessage': + dispatch.focusMessage(action.payload); + break; + } +} + if (IS_SERVICE_WORKER_SUPPORTED) { window.addEventListener('load', async () => { try { @@ -11,7 +32,7 @@ if (IS_SERVICE_WORKER_SUPPORTED) { if (DEBUG) { // eslint-disable-next-line no-console - console.log('ServiceWorker registered'); + console.log('[SW] ServiceWorker registered'); } await navigator.serviceWorker.ready; @@ -19,19 +40,21 @@ if (IS_SERVICE_WORKER_SUPPORTED) { if (navigator.serviceWorker.controller) { if (DEBUG) { // eslint-disable-next-line no-console - console.log('ServiceWorker ready'); + console.log('[SW] ServiceWorker ready'); } + + navigator.serviceWorker.addEventListener('message', handleWorkerMessage); } else { if (DEBUG) { // eslint-disable-next-line no-console - console.error('ServiceWorker not available'); + console.error('[SW] ServiceWorker not available'); } getDispatch().showError({ error: { message: 'SERVICE_WORKER_DISABLED' } }); } } catch (err) { if (DEBUG) { // eslint-disable-next-line no-console - console.error('ServiceWorker registration failed: ', err); + console.error('[SW] ServiceWorker registration failed: ', err); } } });