From 1a017f27509abbb17411b0a5326c9c68cc0479f1 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Sat, 8 May 2021 22:41:31 +0300 Subject: [PATCH] Notifications: Various fixes, support local notifications (#1064) --- ...id-chrome-192x192.png => icon-192x192.png} | Bin ...id-chrome-384x384.png => icon-384x384.png} | Bin ...id-chrome-512x512.png => icon-512x512.png} | Bin public/site.webmanifest | 11 +- src/modules/actions/api/initial.ts | 4 +- src/modules/actions/api/sync.ts | 2 +- src/modules/actions/apiUpdaters/chats.ts | 2 + src/modules/actions/apiUpdaters/initial.ts | 4 +- src/modules/helpers/messages.ts | 56 +++- src/serviceWorker/pushNotification.ts | 24 +- src/util/notifications.ts | 272 ++++++++++++++++++ src/util/pushNotifications.ts | 125 -------- 12 files changed, 357 insertions(+), 143 deletions(-) rename public/{android-chrome-192x192.png => icon-192x192.png} (100%) rename public/{android-chrome-384x384.png => icon-384x384.png} (100%) rename public/{android-chrome-512x512.png => icon-512x512.png} (100%) create mode 100644 src/util/notifications.ts delete mode 100644 src/util/pushNotifications.ts diff --git a/public/android-chrome-192x192.png b/public/icon-192x192.png similarity index 100% rename from public/android-chrome-192x192.png rename to public/icon-192x192.png diff --git a/public/android-chrome-384x384.png b/public/icon-384x384.png similarity index 100% rename from public/android-chrome-384x384.png rename to public/icon-384x384.png diff --git a/public/android-chrome-512x512.png b/public/icon-512x512.png similarity index 100% rename from public/android-chrome-512x512.png rename to public/icon-512x512.png diff --git a/public/site.webmanifest b/public/site.webmanifest index b95d0a19b..8a046cd56 100644 --- a/public/site.webmanifest +++ b/public/site.webmanifest @@ -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" } diff --git a/src/modules/actions/api/initial.ts b/src/modules/actions/api/initial.ts index 287cc723f..308ff842a 100644 --- a/src/modules/actions/api/initial.ts +++ b/src/modules/actions/api/initial.ts @@ -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(); diff --git a/src/modules/actions/api/sync.ts b/src/modules/actions/api/sync.ts index 77b8877cf..2a9078e33 100644 --- a/src/modules/actions/api/sync.ts +++ b/src/modules/actions/api/sync.ts @@ -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, diff --git a/src/modules/actions/apiUpdaters/chats.ts b/src/modules/actions/apiUpdaters/chats.ts index 6a4c43496..ada0d3bb7 100644 --- a/src/modules/actions/apiUpdaters/chats.ts +++ b/src/modules/actions/apiUpdaters/chats.ts @@ -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 && { diff --git a/src/modules/actions/apiUpdaters/initial.ts b/src/modules/actions/apiUpdaters/initial.ts index ea5f43117..113f1adaf 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 { 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); } diff --git a/src/modules/helpers/messages.ts b/src/modules/helpers/messages.ts index b616b710d..fdaa6122d 100644 --- a/src/modules/helpers/messages.ts +++ b/src/modules/helpers/messages.ts @@ -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, diff --git a/src/serviceWorker/pushNotification.ts b/src/serviceWorker/pushNotification.ts index ba90d4452..02c26f605 100644 --- a/src/serviceWorker/pushNotification.ts +++ b/src/serviceWorker/pushNotification.ts @@ -24,6 +24,7 @@ export type NotificationData = { }; const clickBuffer: Record = {}; +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); + } } diff --git a/src/util/notifications.ts b/src/util/notifications.ts new file mode 100644 index 000000000..2b32bfaf9 --- /dev/null +++ b/src/util/notifications.ts @@ -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 }) { + 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', + }); +} diff --git a/src/util/pushNotifications.ts b/src/util/pushNotifications.ts deleted file mode 100644 index bd852f5d0..000000000 --- a/src/util/pushNotifications.ts +++ /dev/null @@ -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', - }); -}