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',
'topPeers',
'recentEmojis',
'push',
]),
isChatInfoShown: reduceShowChatInfo(global),
users: reduceUsers(global),

View File

@ -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' |

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';
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`

View File

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

View File

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

View File

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

View File

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