Notifications: Various fixes, support local notifications (#1064)

This commit is contained in:
Alexander Zinchuk 2021-05-08 22:41:31 +03:00
parent 146a0db3c2
commit 1a017f2750
12 changed files with 357 additions and 143 deletions

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

@ -5,25 +5,22 @@
"gcm_sender_id": "795646555577",
"icons": [
{
"src": "android-chrome-192x192.png",
"src": "icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "android-chrome-384x384.png",
"src": "icon-384x384.png",
"sizes": "384x384",
"type": "image/png"
},
{
"src": "android-chrome-512x512.png",
"src": "icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone",
"permissions": [
"notifications"
]
"display": "standalone"
}

View File

@ -13,7 +13,7 @@ import {
MEDIA_PROGRESSIVE_CACHE_NAME,
} from '../../../config';
import { initApi, callApi } from '../../../api/gramjs';
import { unsubscribeFromPush } from '../../../util/pushNotifications';
import { unsubscribe } from '../../../util/notifications';
import * as cacheApi from '../../../util/cacheApi';
addReducer('initApi', (global: GlobalState, actions) => {
@ -107,7 +107,7 @@ addReducer('saveSession', (global, actions, payload) => {
addReducer('signOut', () => {
(async () => {
await unsubscribeFromPush();
await unsubscribe();
await callApi('destroy');
getDispatch().reset();

View File

@ -12,7 +12,7 @@ import {
} from '../../../config';
import { callApi } from '../../../api/gramjs';
import { buildCollectionByKey } from '../../../util/iteratees';
import { notifyClientReady } from '../../../util/pushNotifications';
import { notifyClientReady } from '../../../util/notifications';
import {
replaceChatListIds,
replaceChats,

View File

@ -4,6 +4,7 @@ import { ApiUpdate } from '../../../api/types';
import { ARCHIVED_FOLDER_ID, MAX_ACTIVE_PINNED_CHATS } from '../../../config';
import { pick } from '../../../util/iteratees';
import { showNewMessageNotification } from '../../../util/notifications';
import {
updateChat,
replaceChatListIds,
@ -110,6 +111,7 @@ addReducer('apiUpdate', (global, actions, update: ApiUpdate) => {
actions.requestChatUpdate({ chatId: update.chatId });
}, CURRENT_CHAT_UNREAD_DELAY);
} else {
showNewMessageNotification({ chat, message });
setGlobal(updateChat(global, update.chatId, {
unreadCount: chat.unreadCount ? chat.unreadCount + 1 : 1,
...(update.message.hasUnreadMention && {

View File

@ -12,7 +12,7 @@ import {
ApiUpdateCurrentUser,
} from '../../../api/types';
import { DEBUG } from '../../../config';
import { subscribeToPush } from '../../../util/pushNotifications';
import { subscribe } from '../../../util/notifications';
import { updateUser } from '../../reducers';
import { setLanguage } from '../../../util/langProvider';
@ -57,7 +57,7 @@ addReducer('apiUpdate', (global, actions, update: ApiUpdate) => {
});
function onUpdateApiReady(global: GlobalState) {
subscribeToPush();
subscribe();
setLanguage(global.settings.byKey.language);
}

View File

@ -64,7 +64,7 @@ export function getMessageSummaryText(message: ApiMessage, noEmoji = false) {
}
if (sticker) {
return `Sticker ${sticker.emoji}`;
return `${sticker.emoji} Sticker`;
}
if (audio) {
@ -107,6 +107,60 @@ export function getMessageSummaryText(message: ApiMessage, noEmoji = false) {
return CONTENT_NOT_SUPPORTED;
}
export function getNotificationText(message: ApiMessage) {
const {
text, photo, video, audio, voice, document, sticker, contact, poll, invoice,
} = message.content;
if (message.groupedId) {
return `🖼 ${text ? text.text : 'Album'}`;
}
if (photo) {
return `🖼 ${text ? text.text : 'Photo'}`;
}
if (video) {
return `📹 ${text ? text.text : video.isGif ? 'GIF' : 'Video'}`;
}
if (sticker) {
return `${sticker.emoji} Sticker `;
}
if (audio) {
const caption = [audio.title, audio.performer].filter(Boolean).join(' — ') || (text && text.text);
return `🎧 ${caption || 'Audio'}`;
}
if (voice) {
return `🎤 ${text ? text.text : 'Voice Message'}`;
}
if (document) {
return `📎 ${text ? text.text : document.fileName}`;
}
if (contact) {
return 'Contact';
}
if (poll) {
return `📊 ${poll.summary.question}`;
}
if (invoice) {
return 'Invoice';
}
if (text) {
return text.text;
}
return CONTENT_NOT_SUPPORTED;
}
export function getMessageText(message: ApiMessage) {
const {
text, sticker, photo, video, audio, voice, document, poll, webPage, contact, invoice,

View File

@ -24,6 +24,7 @@ export type NotificationData = {
};
const clickBuffer: Record<string, NotificationData> = {};
const shownNotifications = new Set();
function getPushData(e: PushEvent | Notification): NotificationData | undefined {
try {
@ -51,6 +52,13 @@ export function handlePush(e: PushEvent) {
// Do not show muted notifications
if (!data || data.mute === Boolean.True) return;
// Dont show already triggered notification
const messageId = getMessageId(data);
if (shownNotifications.has(messageId)) {
shownNotifications.delete(messageId);
return;
}
const title = data.title || process.env.APP_INFO!;
const body = data.description;
@ -58,7 +66,9 @@ export function handlePush(e: PushEvent) {
self.registration.showNotification(title, {
body,
data,
icon: 'android-chrome-192x192.png',
icon: 'icon-192x192.png',
badge: 'icon-192x192.png',
vibrate: [200, 100, 200],
}),
);
}
@ -103,7 +113,6 @@ function focusChatMessage(client: WindowClient, data: NotificationData) {
export function handleNotificationClick(e: NotificationEvent) {
const appUrl = process.env.APP_URL!;
e.notification.close(); // Android needs explicit close.
const { data } = e.notification;
const notifyClients = async () => {
const clients = await self.clients.matchAll({ type: 'window' }) as WindowClient[];
@ -127,13 +136,18 @@ export function handleClientMessage(e: ExtendableMessageEvent) {
// 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;
if (!e.data) return;
const source = e.source as WindowClient;
if (e.data.type === 'clientReady') {
// focus on chat message when client is fully ready
if (clickBuffer[source.id]) {
focusChatMessage(source, clickBuffer[source.id]);
delete clickBuffer[source.id];
}
}
if (e.data.type === 'newMessageNotification') {
// store messageId for already shown notification
const { messageId } = e.data.payload;
shownNotifications.add(messageId);
}
}

272
src/util/notifications.ts Normal file
View File

@ -0,0 +1,272 @@
import { callApi } from '../api/gramjs';
import { ApiChat, ApiMessage } from '../api/types';
import { renderActionMessageText } from '../components/common/helpers/renderActionMessageText';
import { DEBUG } from '../config';
import { getDispatch, getGlobal } from '../lib/teact/teactn';
import {
getChatTitle,
getMessageAction,
getMessageSenderName,
getNotificationText,
getPrivateChatUserId,
isActionMessage,
isChatChannel,
} from '../modules/helpers';
import { selectChatMessage, selectUser } from '../modules/selectors';
import { IS_SERVICE_WORKER_SUPPORTED } from './environment';
function getDeviceToken(subscription: PushSubscription) {
const data = subscription.toJSON();
return JSON.stringify({
endpoint: data.endpoint,
keys: data.keys,
});
}
function checkIfPushSupported() {
if (!IS_SERVICE_WORKER_SUPPORTED) return false;
if (!('showNotification' in ServiceWorkerRegistration.prototype)) {
if (DEBUG) {
// eslint-disable-next-line no-console
console.warn('[PUSH] Push notifications aren\'t supported.');
}
return false;
}
// Check the current Notification permission.
// If its denied, it's a permanent block until the
// user changes the permission
if (Notification.permission === 'denied') {
if (DEBUG) {
// eslint-disable-next-line no-console
console.warn('[PUSH] The user has blocked push notifications.');
}
return false;
}
// Check if push messaging is supported
if (!('PushManager' in window)) {
if (DEBUG) {
// eslint-disable-next-line no-console
console.warn('[PUSH] Push messaging isn\'t supported.');
}
return false;
}
return true;
}
function checkIfNotificationsSupported() {
// Let's check if the browser supports notifications
if (!('Notification' in window)) {
if (DEBUG) {
// eslint-disable-next-line no-console
console.warn('[PUSH] This browser does not support desktop notification');
}
return false;
}
if (Notification.permission === 'denied' as NotificationPermission) {
if (DEBUG) {
// eslint-disable-next-line no-console
console.warn('[PUSH] The user has blocked push notifications.');
}
return false;
}
return true;
}
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 requestPermission() {
if (!('Notification' in window)) return;
if (!['granted', 'denied'].includes(Notification.permission)) {
await Notification.requestPermission();
}
}
async function unsubscribeFromPush(subscription: PushSubscription | null) {
const global = getGlobal();
const dispatch = getDispatch();
if (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
console.log('[PUSH] Unable to unsubscribe from push.', error);
}
}
}
if (global.push) {
await callApi('unregisterDevice', global.push.deviceToken);
dispatch.deleteDeviceToken();
}
}
export async function unsubscribe() {
if (!checkIfPushSupported()) return;
const serviceWorkerRegistration = await navigator.serviceWorker.ready;
const subscription = await serviceWorkerRegistration.pushManager.getSubscription();
await unsubscribeFromPush(subscription);
}
export async function subscribe() {
await requestPermission();
if (!checkIfPushSupported()) return;
const serviceWorkerRegistration = await navigator.serviceWorker.ready;
let subscription = await serviceWorkerRegistration.pushManager.getSubscription();
if (!checkIfShouldResubscribe(subscription)) return;
await unsubscribeFromPush(subscription);
try {
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);
}
await callApi('registerDevice', deviceToken);
getDispatch()
.setDeviceToken(deviceToken);
} catch (error) {
if (Notification.permission === 'denied' as NotificationPermission) {
// The user denied the notification permission which
// means we failed to subscribe and the user will need
// to manually change the notification permission to
// subscribe to push messages
if (DEBUG) {
// eslint-disable-next-line no-console
console.warn('[PUSH] The user has blocked push notifications.');
}
} else if (DEBUG) {
// A problem occurred with the subscription, this can
// often be down to an issue or lack of the gcm_sender_id
// and / or gcm_user_visible_only
// eslint-disable-next-line no-console
console.log('[PUSH] Unable to subscribe to push.', error);
}
}
}
async function checkIfShouldNotify(chat: ApiChat) {
if (chat.isMuted) return false;
await getDispatch().loadNotificationsSettings();
const global = getGlobal();
switch (chat.type) {
case 'chatTypePrivate':
case 'chatTypeSecret':
return Boolean(global.settings.byKey.hasPrivateChatsNotifications);
case 'chatTypeBasicGroup':
case 'chatTypeSuperGroup':
return Boolean(global.settings.byKey.hasGroupNotifications);
case 'chatTypeChannel':
return Boolean(global.settings.byKey.hasBroadcastNotifications);
}
return false;
}
function getNotificationContent(chat: ApiChat, message: ApiMessage) {
const global = getGlobal();
const {
senderId,
replyToMessageId,
} = message;
const messageSender = senderId ? selectUser(global, senderId) : undefined;
const messageAction = getMessageAction(message as ApiMessage);
const actionTargetMessage = messageAction && replyToMessageId
? selectChatMessage(global, chat.id, replyToMessageId)
: undefined;
const {
targetUserId: actionTargetUserId,
targetChatId: actionTargetChatId,
} = messageAction || {};
const actionTargetUser = actionTargetUserId ? selectUser(global, actionTargetUserId) : undefined;
const privateChatUserId = getPrivateChatUserId(chat);
const privateChatUser = privateChatUserId ? selectUser(global, privateChatUserId) : undefined;
let body: string;
if (isActionMessage(message)) {
const actionOrigin = chat && (isChatChannel(chat) || message.senderId === message.chatId)
? chat
: messageSender;
body = renderActionMessageText(
message,
actionOrigin,
actionTargetUser,
actionTargetMessage,
actionTargetChatId,
{ asPlain: true },
) as string;
} else {
const senderName = getMessageSenderName(chat.id, messageSender);
const summary = getNotificationText(message);
body = senderName ? `${senderName}: ${summary}` : summary;
}
return {
title: getChatTitle(chat, privateChatUser),
body,
};
}
export async function showNewMessageNotification({
chat,
message,
}: { chat: ApiChat; message: Partial<ApiMessage> }) {
if (!checkIfNotificationsSupported()) return;
if (!message.id) return;
const shouldNotify = await checkIfShouldNotify(chat);
if (!shouldNotify) return;
const {
title,
body,
} = getNotificationContent(chat, message as ApiMessage);
const notification = new Notification(title, {
body,
icon: 'icon-192x192.png',
badge: 'icon-192x192.png',
tag: message.id.toString(),
vibrate: [200, 100, 200],
});
if (navigator.serviceWorker.controller) {
// notify service worker about new message notification
navigator.serviceWorker.controller.postMessage({
type: 'newMessageNotification',
payload: { messageId: message.id },
});
}
const dispatch = getDispatch();
notification.onclick = () => {
notification.close();
dispatch.focusMessage({
chatId: chat.id,
messageId: message.id,
});
};
}
// Notify service worker that client is fully loaded
export function notifyClientReady() {
if (!navigator.serviceWorker.controller) return;
navigator.serviceWorker.controller.postMessage({
type: 'clientReady',
});
}

View File

@ -1,125 +0,0 @@
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) {
const data = subscription.toJSON();
return JSON.stringify({ endpoint: data.endpoint, keys: data.keys });
}
function checkIfSupported() {
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 false;
}
// Check the current Notification permission.
// If its denied, it's a permanent block until the
// user changes the permission
if (Notification.permission === 'denied') {
if (DEBUG) {
// eslint-disable-next-line no-console
console.log('[PUSH] The user has blocked push notifications.');
}
return false;
}
// Check if push messaging is supported
if (!('PushManager' in window)) {
if (DEBUG) {
// eslint-disable-next-line no-console
console.log('[PUSH] Push messaging isn\'t supported.');
}
return false;
}
return true;
}
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) {
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
console.log('[PUSH] Unable to unsubscribe from push.', error);
}
}
}
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 (!checkIfSupported()) return;
const serviceWorkerRegistration = await navigator.serviceWorker.ready;
let subscription = await serviceWorkerRegistration.pushManager.getSubscription();
if (!checkIfShouldResubscribe(subscription)) return;
await unsubscribe(subscription);
try {
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);
}
await callApi('registerDevice', deviceToken);
getDispatch().setDeviceToken(deviceToken);
} catch (error) {
if (Notification.permission === 'denied' as NotificationPermission) {
// The user denied the notification permission which
// means we failed to subscribe and the user will need
// to manually change the notification permission to
// subscribe to push messages
if (DEBUG) {
// eslint-disable-next-line no-console
console.log('[PUSH] Permission for Notifications was denied');
}
} else if (DEBUG) {
// A problem occurred with the subscription, this can
// often be down to an issue or lack of the gcm_sender_id
// and / or gcm_user_visible_only
// eslint-disable-next-line no-console
console.log('[PUSH] Unable to subscribe to push.', error);
}
}
}
// Notify service worker that client is fully loaded
export function notifyClientReady() {
if (!navigator.serviceWorker.controller) return;
navigator.serviceWorker.controller.postMessage({
type: 'clientReady',
});
}