Push Notifications: Open chat message on notification click, various fixes (#1055)

This commit is contained in:
Alexander Zinchuk 2021-05-01 19:32:42 +03:00
parent 5fb8c40cec
commit e053013219
8 changed files with 194 additions and 61 deletions

View File

@ -110,6 +110,7 @@ function updateCache() {
'chatFolders', 'chatFolders',
'topPeers', 'topPeers',
'recentEmojis', 'recentEmojis',
'push',
]), ]),
isChatInfoShown: reduceShowChatInfo(global), isChatInfoShown: reduceShowChatInfo(global),
users: reduceUsers(global), users: reduceUsers(global),

View File

@ -375,6 +375,11 @@ export type GlobalState = {
error?: string; error?: string;
waitingEmailCodeLength?: number; waitingEmailCodeLength?: number;
}; };
push?: {
deviceToken: string;
subscribedAt: number;
};
}; };
export type ActionTypes = ( export type ActionTypes = (
@ -439,7 +444,8 @@ export type ActionTypes = (
'clickInlineButton' | 'sendBotCommand' | 'clickInlineButton' | 'sendBotCommand' |
// misc // misc
'openMediaViewer' | 'closeMediaViewer' | 'openAudioPlayer' | 'closeAudioPlayer' | 'openPollModal' | 'closePollModal' | 'openMediaViewer' | 'closeMediaViewer' | 'openAudioPlayer' | 'closeAudioPlayer' | 'openPollModal' | 'closePollModal' |
'loadWebPagePreview' | 'clearWebPagePreview' | 'loadWallpapers' | 'uploadWallpaper' | 'loadWebPagePreview' | 'clearWebPagePreview' | 'loadWallpapers' | 'uploadWallpaper' | 'setDeviceToken' |
'deleteDeviceToken' |
// payment // payment
'openPaymentModal' | 'closePaymentModal' | 'openPaymentModal' | 'closePaymentModal' |
'validateRequestedInfo' | 'setPaymentStep' | 'sendPaymentForm' | 'getPaymentForm' | 'getReceipt' | 'validateRequestedInfo' | 'setPaymentStep' | 'sendPaymentForm' | 'getPaymentForm' | 'getReceipt' |

View File

@ -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);
});

View File

@ -12,6 +12,7 @@ import {
} from '../../../config'; } from '../../../config';
import { callApi } from '../../../api/gramjs'; import { callApi } from '../../../api/gramjs';
import { buildCollectionByKey } from '../../../util/iteratees'; import { buildCollectionByKey } from '../../../util/iteratees';
import { notifyClientReady } from '../../../util/pushNotifications';
import { import {
replaceChatListIds, replaceChatListIds,
replaceChats, replaceChats,
@ -41,6 +42,9 @@ async function sync(afterSyncCallback: () => void) {
console.log('>>> START SYNC'); console.log('>>> START SYNC');
} }
// Notify web worker that client is ready to receive messages
notifyClientReady();
await callApi('fetchCurrentUser'); await callApi('fetchCurrentUser');
// This fetches only active chats and clears archived chats, which will be fetched in `afterSync` // This fetches only active chats and clears archived chats, which will be fetched in `afterSync`

View File

@ -1,7 +1,7 @@
import { DEBUG } from './config'; import { DEBUG } from './config';
import { respondForProgressive } from './serviceWorker/progressive'; import { respondForProgressive } from './serviceWorker/progressive';
import { respondWithCache, clearAssetCache } from './serviceWorker/assetCache'; import { respondWithCache, clearAssetCache } from './serviceWorker/assetCache';
import { handlePush, handleNotificationClick } from './serviceWorker/pushNotification'; import { handlePush, handleNotificationClick, handleClientMessage } from './serviceWorker/pushNotification';
declare const self: ServiceWorkerGlobalScope; declare const self: ServiceWorkerGlobalScope;
@ -48,3 +48,4 @@ self.addEventListener('fetch', (e: FetchEvent) => {
self.addEventListener('push', handlePush); self.addEventListener('push', handlePush);
self.addEventListener('notificationclick', handleNotificationClick); self.addEventListener('notificationclick', handleNotificationClick);
self.addEventListener('message', handleClientMessage);

View File

@ -2,26 +2,35 @@ import { DEBUG } from '../config';
declare const self: ServiceWorkerGlobalScope; declare const self: ServiceWorkerGlobalScope;
export enum NotificationType {
MESSAGE_TEXT = 'MESSAGE_TEXT',
MESSAGE_NOTEXT = 'MESSAGE_NOTEXT',
MESSAGE_STICKER = 'MESSAGE_STICKER'
}
export type NotificationData = { export type NotificationData = {
custom: { custom: {
msg_id: string; msg_id?: string;
from_id: string; channel_id?: string;
chat_id?: string;
from_id?: string;
}; };
mute: '0' | '1'; mute: '0' | '1';
badge: '0' | '1'; badge: '0' | '1';
loc_key: NotificationType; loc_key: string;
loc_args: string[]; loc_args: string[];
random_id: number; random_id: number;
title: string; title: string;
description: string; description: string;
}; };
const clickBuffer: Record<string, NotificationData> = {};
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) { export function handlePush(e: PushEvent) {
if (DEBUG) { if (DEBUG) {
@ -32,49 +41,92 @@ export function handlePush(e: PushEvent) {
console.log('[SW] Push received with data', e.data.json()); console.log('[SW] Push received with data', e.data.json());
} }
} }
if (!e.data) return; const data = getPushData(e);
let data: NotificationData; if (!data) return;
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 title = data.title || process.env.APP_INFO!;
const body = data.description; const body = data.description;
const options = {
body,
icon: 'android-chrome-192x192.png',
};
e.waitUntil( 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) { export function handleNotificationClick(e: NotificationEvent) {
const appUrl = process.env.APP_URL!; const appUrl = process.env.APP_URL!;
e.notification.close(); // Android needs explicit close. e.notification.close(); // Android needs explicit close.
e.waitUntil(
self.clients.matchAll({ type: 'window' }) const { data } = e.notification;
.then((windowClients) => { const notifyClients = async () => {
// Check if there is already a window/tab open with the target URL const clients = await self.clients.matchAll({ type: 'window' }) as WindowClient[];
for (let i = 0; i < windowClients.length; i++) { const clientsInScope = clients.filter((client) => client.url === self.registration.scope);
const client = windowClients[i] as WindowClient; clientsInScope.forEach((client) => focusChatMessage(client, data));
// If so, just focus it. if (!self.clients.openWindow || clientsInScope.length > 0) return undefined;
if (client.url === self.registration.scope && client.focus) {
client.focus(); // If there is no opened client we need to open one and wait until it is fully loaded
return; const newClient = await self.clients.openWindow(appUrl);
} if (newClient) {
} // Store notification data until client is fully loaded
// If not, then open the target URL in a new window/tab. clickBuffer[newClient.id] = data;
if (self.clients.openWindow) { }
self.clients.openWindow(appUrl); 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];
}
}
} }

View File

@ -1,5 +1,6 @@
import { callApi } from '../api/gramjs'; import { callApi } from '../api/gramjs';
import { DEBUG } from '../config'; import { DEBUG } from '../config';
import { getDispatch, getGlobal } from '../lib/teact/teactn';
import { IS_SERVICE_WORKER_SUPPORTED } from './environment'; import { IS_SERVICE_WORKER_SUPPORTED } from './environment';
function getDeviceToken(subscription: PushSubscription) { function getDeviceToken(subscription: PushSubscription) {
@ -7,7 +8,7 @@ function getDeviceToken(subscription: PushSubscription) {
return JSON.stringify({ endpoint: data.endpoint, keys: data.keys }); return JSON.stringify({ endpoint: data.endpoint, keys: data.keys });
} }
export function isPushSupported() { function checkIfSupported() {
if (!IS_SERVICE_WORKER_SUPPORTED) return false; if (!IS_SERVICE_WORKER_SUPPORTED) return false;
if (!('showNotification' in ServiceWorkerRegistration.prototype)) { if (!('showNotification' in ServiceWorkerRegistration.prototype)) {
if (DEBUG) { if (DEBUG) {
@ -39,19 +40,25 @@ export function isPushSupported() {
return true; return true;
} }
export async function unsubscribeFromPush() { const expirationTime = 12 * 60 * 60 * 1000; // 12 hours
if (!isPushSupported) return;
const serviceWorkerRegistration = await navigator.serviceWorker.ready; function checkIfShouldResubscribe(subscription: PushSubscription | null) {
const subscription = await serviceWorkerRegistration.pushManager.getSubscription(); 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 (subscription) {
if (DEBUG) {
// eslint-disable-next-line no-console
console.log('[PUSH] Unsubscribing', 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();
return;
} catch (error) { } catch (error) {
if (DEBUG) { if (DEBUG) {
// eslint-disable-next-line no-console // 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() { export async function subscribeToPush() {
if (!isPushSupported()) return; if (!checkIfSupported()) return;
await unsubscribeFromPush();
const serviceWorkerRegistration = await navigator.serviceWorker.ready; const serviceWorkerRegistration = await navigator.serviceWorker.ready;
let subscription = await serviceWorkerRegistration.pushManager.getSubscription();
if (!checkIfShouldResubscribe(subscription)) return;
await unsubscribe(subscription);
try { try {
const subscription = await serviceWorkerRegistration.pushManager.subscribe({ subscription = await serviceWorkerRegistration.pushManager.subscribe({
userVisibleOnly: true, userVisibleOnly: true,
}); });
const deviceToken = getDeviceToken(subscription); const deviceToken = getDeviceToken(subscription);
@ -75,6 +95,7 @@ export async function subscribeToPush() {
console.log('[PUSH] Received push subscription: ', deviceToken); console.log('[PUSH] Received push subscription: ', deviceToken);
} }
await callApi('registerDevice', deviceToken); await callApi('registerDevice', deviceToken);
getDispatch().setDeviceToken(deviceToken);
} catch (error) { } catch (error) {
if (Notification.permission === 'denied' as NotificationPermission) { if (Notification.permission === 'denied' as NotificationPermission) {
// The user denied the notification permission which // 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',
});
}

View File

@ -4,6 +4,27 @@ import { DEBUG } from '../config';
import { getDispatch } from '../lib/teact/teactn'; import { getDispatch } from '../lib/teact/teactn';
import { IS_SERVICE_WORKER_SUPPORTED } from './environment'; import { IS_SERVICE_WORKER_SUPPORTED } from './environment';
type WorkerAction = {
type: string;
payload: Record<string, any>;
};
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) { if (IS_SERVICE_WORKER_SUPPORTED) {
window.addEventListener('load', async () => { window.addEventListener('load', async () => {
try { try {
@ -11,7 +32,7 @@ if (IS_SERVICE_WORKER_SUPPORTED) {
if (DEBUG) { if (DEBUG) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log('ServiceWorker registered'); console.log('[SW] ServiceWorker registered');
} }
await navigator.serviceWorker.ready; await navigator.serviceWorker.ready;
@ -19,19 +40,21 @@ if (IS_SERVICE_WORKER_SUPPORTED) {
if (navigator.serviceWorker.controller) { if (navigator.serviceWorker.controller) {
if (DEBUG) { if (DEBUG) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log('ServiceWorker ready'); console.log('[SW] ServiceWorker ready');
} }
navigator.serviceWorker.addEventListener('message', handleWorkerMessage);
} else { } else {
if (DEBUG) { if (DEBUG) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error('ServiceWorker not available'); console.error('[SW] ServiceWorker not available');
} }
getDispatch().showError({ error: { message: 'SERVICE_WORKER_DISABLED' } }); getDispatch().showError({ error: { message: 'SERVICE_WORKER_DISABLED' } });
} }
} catch (err) { } catch (err) {
if (DEBUG) { if (DEBUG) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error('ServiceWorker registration failed: ', err); console.error('[SW] ServiceWorker registration failed: ', err);
} }
} }
}); });