diff --git a/public/notification.mp3 b/public/notification.mp3 new file mode 100644 index 000000000..0a9f875f2 Binary files /dev/null and b/public/notification.mp3 differ diff --git a/src/components/left/settings/Settings.scss b/src/components/left/settings/Settings.scss index 23c9ccf46..4a83a3f86 100644 --- a/src/components/left/settings/Settings.scss +++ b/src/components/left/settings/Settings.scss @@ -136,6 +136,10 @@ } } + &-slider { + margin-top: 2rem; + } + &-description { font-size: 0.875rem; color: var(--color-text-secondary); diff --git a/src/components/left/settings/SettingsNotifications.tsx b/src/components/left/settings/SettingsNotifications.tsx index 245cad043..17ae4fc45 100644 --- a/src/components/left/settings/SettingsNotifications.tsx +++ b/src/components/left/settings/SettingsNotifications.tsx @@ -12,6 +12,7 @@ import useLang from '../../../hooks/useLang'; import useHistoryBack from '../../../hooks/useHistoryBack'; import Checkbox from '../../ui/Checkbox'; +import RangeSlider from '../../ui/RangeSlider'; type OwnProps = { isActive?: boolean; @@ -27,10 +28,14 @@ type StateProps = { hasBroadcastNotifications: boolean; hasBroadcastMessagePreview: boolean; hasContactJoinedNotifications: boolean; + hasWebNotifications: boolean; + hasPushNotifications: boolean; + notificationSoundVolume: number; }; type DispatchProps = Pick; const SettingsNotifications: FC = ({ @@ -44,9 +49,13 @@ const SettingsNotifications: FC = ({ hasBroadcastNotifications, hasBroadcastMessagePreview, hasContactJoinedNotifications, + hasPushNotifications, + hasWebNotifications, + notificationSoundVolume, loadNotificationSettings, updateContactSignUpNotification, updateNotificationSettings, + updateWebNotificationSettings, }) => { useEffect(() => { loadNotificationSettings(); @@ -88,6 +97,44 @@ const SettingsNotifications: FC = ({ return (
+
+

+ Web notifications +

+ { + updateWebNotificationSettings({ hasWebNotifications: e.target.checked }); + }} + /> + { + updateWebNotificationSettings({ hasPushNotifications: e.target.checked }); + }} + /> +
+ { + updateWebNotificationSettings({ notificationSoundVolume: volume }); + }} + /> +
+

{lang('AutodownloadPrivateChats')} @@ -102,6 +149,7 @@ const SettingsNotifications: FC = ({ /> = ({ /> { handleSettingsChange(e, 'group', 'showPreviews'); }} @@ -138,6 +187,7 @@ const SettingsNotifications: FC = ({ /> ((global): StateProps => { hasBroadcastNotifications: Boolean(global.settings.byKey.hasBroadcastNotifications), hasBroadcastMessagePreview: Boolean(global.settings.byKey.hasBroadcastMessagePreview), hasContactJoinedNotifications: Boolean(global.settings.byKey.hasContactJoinedNotifications), + hasWebNotifications: global.settings.byKey.hasWebNotifications, + hasPushNotifications: global.settings.byKey.hasPushNotifications, + notificationSoundVolume: global.settings.byKey.notificationSoundVolume, }; }, (setGlobal, actions): DispatchProps => pick(actions, [ 'loadNotificationSettings', 'updateContactSignUpNotification', 'updateNotificationSettings', + 'updateWebNotificationSettings', ]))(SettingsNotifications)); diff --git a/src/global/initial.ts b/src/global/initial.ts index 96d2f8d31..e39852aa6 100644 --- a/src/global/initial.ts +++ b/src/global/initial.ts @@ -133,6 +133,9 @@ export const INITIAL_STATE: GlobalState = { shouldAutoDownloadMediaInPrivateChats: true, shouldAutoDownloadMediaInGroups: true, shouldAutoDownloadMediaInChannels: true, + hasWebNotifications: true, + hasPushNotifications: true, + notificationSoundVolume: 5, shouldAutoPlayGifs: true, shouldAutoPlayVideos: true, shouldSuggestStickers: true, diff --git a/src/global/types.ts b/src/global/types.ts index ed1442d7c..bc48da9a7 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -483,9 +483,9 @@ export type ActionTypes = ( 'loadBlockedContacts' | 'blockContact' | 'unblockContact' | 'loadAuthorizations' | 'terminateAuthorization' | 'terminateAllAuthorizations' | 'loadNotificationSettings' | 'updateContactSignUpNotification' | 'updateNotificationSettings' | - 'loadLanguages' | 'loadPrivacySettings' | 'setPrivacyVisibility' | 'setPrivacySettings' | - 'loadNotificationExceptions' | 'setThemeSettings' | 'updateIsOnline' | 'loadContentSettings' | - 'updateContentSettings' | + 'updateWebNotificationSettings' | 'loadLanguages' | 'loadPrivacySettings' | 'setPrivacyVisibility' | + 'setPrivacySettings' | 'loadNotificationExceptions' | 'setThemeSettings' | 'updateIsOnline' | + 'loadContentSettings' | 'updateContentSettings' | // Stickers & GIFs 'loadStickerSets' | 'loadAddedStickers' | 'loadRecentStickers' | 'loadFavoriteStickers' | 'loadFeaturedStickers' | 'loadStickers' | 'setStickerSearchQuery' | 'loadSavedGifs' | 'setGifSearchQuery' | 'searchMoreGifs' | diff --git a/src/modules/actions/api/settings.ts b/src/modules/actions/api/settings.ts index 82fded80d..b6ad8937b 100644 --- a/src/modules/actions/api/settings.ts +++ b/src/modules/actions/api/settings.ts @@ -8,6 +8,7 @@ import { import { callApi } from '../../../api/gramjs'; import { buildCollectionByKey } from '../../../util/iteratees'; +import { subscribe, unsubscribe } from '../../../util/notifications'; import { selectUser } from '../../selectors'; import { addUsers, addBlockedContact, updateChats, updateUser, removeBlockedContact, replaceSettings, updateNotifySettings, @@ -346,6 +347,19 @@ addReducer('updateNotificationSettings', (global, actions, payload) => { })(); }); +addReducer('updateWebNotificationSettings', (global, actions, payload) => { + (async () => { + setGlobal(replaceSettings(getGlobal(), payload)); + const newGlobal = getGlobal(); + const { hasPushNotifications, hasWebNotifications } = newGlobal.settings.byKey; + if (hasWebNotifications && hasPushNotifications) { + await subscribe(); + } else { + await unsubscribe(); + } + })(); +}); + addReducer('updateContactSignUpNotification', (global, actions, payload) => { const { isSilent } = payload!; diff --git a/src/modules/actions/apiUpdaters/chats.ts b/src/modules/actions/apiUpdaters/chats.ts index 7a613a70c..09ea73a6c 100644 --- a/src/modules/actions/apiUpdaters/chats.ts +++ b/src/modules/actions/apiUpdaters/chats.ts @@ -19,7 +19,7 @@ import { selectIsChatListed, selectChatListType, selectCurrentMessageList, - selectCountNotMutedUnread, + selectCountNotMutedUnread, selectNotifySettings, } from '../../selectors'; import { throttle } from '../../../util/schedulers'; @@ -135,7 +135,15 @@ addReducer('apiUpdate', (global, actions, update: ApiUpdate) => { const unreadCount = selectCountNotMutedUnread(getGlobal()); updateAppBadge(unreadCount); - showNewMessageNotification({ chat, message, isActiveChat }); + + const { hasWebNotifications } = selectNotifySettings(global); + if (hasWebNotifications) { + showNewMessageNotification({ + chat, + message, + isActiveChat, + }); + } break; } diff --git a/src/modules/actions/apiUpdaters/initial.ts b/src/modules/actions/apiUpdaters/initial.ts index 6c38a8264..6c83307ca 100644 --- a/src/modules/actions/apiUpdaters/initial.ts +++ b/src/modules/actions/apiUpdaters/initial.ts @@ -16,6 +16,7 @@ import { DEBUG, SESSION_USER_KEY } from '../../../config'; import { subscribe } from '../../../util/notifications'; import { updateUser } from '../../reducers'; import { setLanguage } from '../../../util/langProvider'; +import { selectNotifySettings } from '../../selectors'; addReducer('apiUpdate', (global, actions, update: ApiUpdate) => { if (DEBUG) { @@ -68,7 +69,8 @@ addReducer('apiUpdate', (global, actions, update: ApiUpdate) => { }); function onUpdateApiReady(global: GlobalState) { - subscribe(); + const { hasWebNotifications, hasPushNotifications } = selectNotifySettings(global); + if (hasWebNotifications && hasPushNotifications) subscribe(); setLanguage(global.settings.byKey.language); } diff --git a/src/modules/helpers/chats.ts b/src/modules/helpers/chats.ts index 3150ec442..e2a05110b 100644 --- a/src/modules/helpers/chats.ts +++ b/src/modules/helpers/chats.ts @@ -225,9 +225,14 @@ export function isChatArchived(chat: ApiChat) { } export function selectIsChatMuted( - chat: ApiChat, notifySettings: NotifySettings, notifyExceptions?: Record, + chat: ApiChat, notifySettings: NotifySettings, notifyExceptions: Record = [], ) { - return !(notifyExceptions && notifyExceptions[chat.id] && !notifyExceptions[chat.id].isMuted) && ( + // If this chat is in exceptions they take precedence + if (notifyExceptions[chat.id] && notifyExceptions[chat.id].isMuted !== undefined) { + return notifyExceptions[chat.id].isMuted; + } + + return ( chat.isMuted || (isChatPrivate(chat.id) && !notifySettings.hasPrivateChatsNotifications) || (isChatChannel(chat) && !notifySettings.hasBroadcastNotifications) @@ -235,6 +240,24 @@ export function selectIsChatMuted( ); } +export function selectShouldShowMessagePreview( + chat: ApiChat, notifySettings: NotifySettings, notifyExceptions: Record = [], +) { + const { + hasPrivateChatsMessagePreview = true, + hasBroadcastMessagePreview = true, + hasGroupMessagePreview = true, + } = notifySettings; + // If this chat is in exceptions they take precedence + if (notifyExceptions[chat.id] && notifyExceptions[chat.id].shouldShowPreviews !== undefined) { + return notifyExceptions[chat.id].shouldShowPreviews; + } + + return (isChatPrivate(chat.id) && hasPrivateChatsMessagePreview) + || (isChatChannel(chat) && hasBroadcastMessagePreview) + || (isChatGroup(chat) && hasGroupMessagePreview); +} + export function getCanDeleteChat(chat: ApiChat) { return isChatBasicGroup(chat) || ((isChatSuperGroup(chat) || isChatChannel(chat)) && chat.isCreator); } diff --git a/src/serviceWorker/pushNotification.ts b/src/serviceWorker/pushNotification.ts index a446a0803..97863e71d 100644 --- a/src/serviceWorker/pushNotification.ts +++ b/src/serviceWorker/pushNotification.ts @@ -28,6 +28,7 @@ type NotificationData = { chatId?: number; title: string; body: string; + icon?: string; }; let lastSyncAt = new Date().valueOf(); @@ -75,22 +76,36 @@ function getNotificationData(data: PushData): NotificationData { }; } -function showNotification({ +async function playNotificationSound(id: number) { + const clients = await self.clients.matchAll({ type: 'window' }) as WindowClient[]; + const clientsInScope = clients.filter((client) => client.url === self.registration.scope); + const client = clientsInScope[0]; + if (!client) return; + if (clientsInScope.length === 0) return; + client.postMessage({ + type: 'playNotificationSound', + payload: { id }, + }); +} + +async function showNotification({ chatId, messageId, body, title, + icon, }: NotificationData) { - return self.registration.showNotification(title, { + await self.registration.showNotification(title, { body, data: { chatId, messageId, }, - icon: 'icon-192x192.png', - badge: 'icon-192x192.png', + icon: icon || 'icon-192x192.png', + badge: icon || 'icon-192x192.png', vibrate: [200, 100, 200], }); + await playNotificationSound(messageId || chatId || 0); } export function handlePush(e: PushEvent) { diff --git a/src/types/index.ts b/src/types/index.ts index 034d3acdf..52358bc76 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -38,6 +38,9 @@ export type NotifySettings = { hasBroadcastNotifications?: boolean; hasBroadcastMessagePreview?: boolean; hasContactJoinedNotifications?: boolean; + hasWebNotifications: boolean; + hasPushNotifications: boolean; + notificationSoundVolume: number; }; export type LangCode = ( diff --git a/src/util/notifications.ts b/src/util/notifications.ts index 81696f6b8..fb987c423 100644 --- a/src/util/notifications.ts +++ b/src/util/notifications.ts @@ -1,9 +1,12 @@ import { callApi } from '../api/gramjs'; -import { ApiChat, ApiMessage, ApiUser } from '../api/types'; +import { + ApiChat, ApiMediaFormat, ApiMessage, ApiUser, +} from '../api/types'; import { renderActionMessageText } from '../components/common/helpers/renderActionMessageText'; import { DEBUG } from '../config'; import { getDispatch, getGlobal, setGlobal } from '../lib/teact/teactn'; import { + getChatAvatarHash, getChatTitle, getMessageAction, getMessageSenderName, @@ -11,7 +14,7 @@ import { getPrivateChatUserId, isActionMessage, isChatChannel, - selectIsChatMuted, + selectIsChatMuted, selectShouldShowMessagePreview, } from '../modules/helpers'; import { getTranslation } from './langProvider'; import { addNotifyExceptions, replaceSettings } from '../modules/reducers'; @@ -19,6 +22,8 @@ import { selectChatMessage, selectNotifyExceptions, selectNotifySettings, selectUser, } from '../modules/selectors'; import { IS_SERVICE_WORKER_SUPPORTED } from './environment'; +import * as mediaLoader from './mediaLoader'; +import { debounce } from './schedulers'; function getDeviceToken(subscription: PushSubscription) { const data = subscription.toJSON(); @@ -81,6 +86,38 @@ function checkIfNotificationsSupported() { } const expirationTime = 12 * 60 * 60 * 1000; // 12 hours +// Notification id is removed from soundPlayed cache after 3 seconds +const soundPlayedDelay = 3 * 1000; +const soundPlayed = new Set(); + +async function playSound(id: number) { + if (soundPlayed.has(id)) return; + const { notificationSoundVolume } = selectNotifySettings(getGlobal()); + const volume = notificationSoundVolume / 10; + if (volume === 0) return; + + const audio = new Audio('/notification.mp3'); + audio.volume = volume; + audio.setAttribute('mozaudiochannel', 'notification'); + audio.addEventListener('ended', () => { + soundPlayed.add(id); + }, { once: true }); + + setTimeout(() => { + soundPlayed.delete(id); + }, soundPlayedDelay); + + try { + await audio.play(); + } catch (error) { + if (DEBUG) { + // eslint-disable-next-line no-console + console.warn('[PUSH] Unable to play notification sound'); + } + } +} + +export const playNotificationSound = debounce(playSound, 1000, true, false); function checkIfShouldResubscribe(subscription: PushSubscription | null) { const global = getGlobal(); @@ -202,8 +239,8 @@ export async function subscribe() { function checkIfShouldNotify(chat: ApiChat, isActive: boolean) { if (!areSettingsLoaded) return false; const global = getGlobal(); - if (selectIsChatMuted(chat, selectNotifySettings(global), selectNotifyExceptions(global)) || chat.isNotJoined - || !chat.isListed) { + const isMuted = selectIsChatMuted(chat, selectNotifySettings(global), selectNotifyExceptions(global)); + if (isMuted || chat.isNotJoined || !chat.isListed) { return false; } // Dont show notification for active chat if client has focus @@ -216,6 +253,7 @@ function getNotificationContent(chat: ApiChat, message: ApiMessage) { senderId, replyToMessageId, } = message; + const messageSender = senderId ? selectUser(global, senderId) : undefined; const messageAction = getMessageAction(message as ApiMessage); const actionTargetMessage = messageAction && replyToMessageId @@ -227,29 +265,35 @@ function getNotificationContent(chat: ApiChat, message: ApiMessage) { } = messageAction || {}; const actionTargetUsers = actionTargetUserIds - ? actionTargetUserIds.map((userId) => selectUser(global, userId)).filter(Boolean as any) + ? actionTargetUserIds.map((userId) => selectUser(global, userId)) + .filter(Boolean as any) : 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( - getTranslation, - message, - actionOrigin, - actionTargetUsers, - actionTargetMessage, - actionTargetChatId, - { asPlain: true }, - ) as string; - } else { - const senderName = getMessageSenderName(getTranslation, chat.id, messageSender); - const summary = getMessageSummaryText(getTranslation, message); - body = senderName ? `${senderName}: ${summary}` : summary; + let body: string; + if (selectShouldShowMessagePreview(chat, selectNotifySettings(global), selectNotifyExceptions(global))) { + if (isActionMessage(message)) { + const actionOrigin = chat && (isChatChannel(chat) || message.senderId === message.chatId) + ? chat + : messageSender; + body = renderActionMessageText( + getTranslation, + message, + actionOrigin, + actionTargetUsers, + actionTargetMessage, + actionTargetChatId, + { asPlain: true }, + ) as string; + } else { + const senderName = getMessageSenderName(getTranslation, chat.id, messageSender); + const summary = getMessageSummaryText(getTranslation, message); + + body = senderName ? `${senderName}: ${summary}` : summary; + } + } else { + body = 'New message'; } return { @@ -258,11 +302,22 @@ function getNotificationContent(chat: ApiChat, message: ApiMessage) { }; } +async function getAvatar(chat: ApiChat) { + const imageHash = getChatAvatarHash(chat); + if (!imageHash) return undefined; + let mediaData = mediaLoader.getFromMemory(imageHash); + if (!mediaData) { + await mediaLoader.fetch(imageHash, ApiMediaFormat.BlobUrl); + mediaData = mediaLoader.getFromMemory(imageHash); + } + return mediaData; +} + export async function showNewMessageNotification({ chat, message, isActiveChat, -}: { chat: ApiChat; message: Partial; isActiveChat: boolean}) { +}: { chat: ApiChat; message: Partial; isActiveChat: boolean }) { if (!checkIfNotificationsSupported()) return; if (!message.id) return; @@ -274,6 +329,8 @@ export async function showNewMessageNotification({ body, } = getNotificationContent(chat, message as ApiMessage); + const icon = await getAvatar(chat); + if (checkIfPushSupported()) { if (navigator.serviceWorker.controller) { // notify service worker about new message notification @@ -282,6 +339,7 @@ export async function showNewMessageNotification({ payload: { title, body, + icon, chatId: chat.id, messageId: message.id, }, @@ -291,8 +349,8 @@ export async function showNewMessageNotification({ const dispatch = getDispatch(); const options: NotificationOptions = { body, - icon: 'icon-192x192.png', - badge: 'icon-192x192.png', + icon, + badge: icon, tag: message.id.toString(), }; @@ -312,6 +370,11 @@ export async function showNewMessageNotification({ window.focus(); } }; + + // Play sound when notification is displayed + notification.onshow = () => { + playNotificationSound(message.id || chat.id); + }; } } diff --git a/src/util/setupServiceWorker.ts b/src/util/setupServiceWorker.ts index e2d859407..c1ef15197 100644 --- a/src/util/setupServiceWorker.ts +++ b/src/util/setupServiceWorker.ts @@ -3,7 +3,7 @@ import { scriptUrl } from 'service-worker-loader!../serviceWorker'; import { DEBUG } from '../config'; import { getDispatch } from '../lib/teact/teactn'; import { IS_ANDROID, IS_IOS, IS_SERVICE_WORKER_SUPPORTED } from './environment'; -import { notifyClientReady } from './notifications'; +import { notifyClientReady, playNotificationSound } from './notifications'; type WorkerAction = { type: string; @@ -16,7 +16,12 @@ function handleWorkerMessage(e: MessageEvent) { const dispatch = getDispatch(); switch (action.type) { case 'focusMessage': - dispatch.focusMessage(action.payload); + if (dispatch.focusMessage) { + dispatch.focusMessage(action.payload); + } + break; + case 'playNotificationSound': + playNotificationSound(action.payload.id); break; } }