From e701bf983692430a9feac0de1e48403fb6b6eaa6 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Sun, 20 Feb 2022 13:39:27 +0200 Subject: [PATCH] Notifications: Support reactions (#1709) --- src/global/types.ts | 2 +- src/modules/actions/api/reactions.ts | 24 ++++++ src/modules/actions/apiUpdaters/chats.ts | 21 ++++- src/modules/actions/apiUpdaters/messages.ts | 18 +++- src/modules/helpers/index.ts | 1 + src/modules/helpers/messageSummary.ts | 91 +++++++++++++++------ src/modules/helpers/reactions.ts | 15 ++++ src/serviceWorker/pushNotification.ts | 39 +++++---- src/util/notifications.ts | 54 ++++++++---- src/util/setupServiceWorker.ts | 9 +- 10 files changed, 214 insertions(+), 60 deletions(-) create mode 100644 src/modules/helpers/reactions.ts diff --git a/src/global/types.ts b/src/global/types.ts index dbc3715d7..0fc376511 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -534,7 +534,7 @@ export type ActionTypes = ( 'loadSponsoredMessages' | 'viewSponsoredMessage' | 'loadSendAs' | 'saveDefaultSendAs' | 'loadAvailableReactions' | 'stopActiveEmojiInteraction' | 'interactWithAnimatedEmoji' | 'loadReactors' | 'setDefaultReaction' | 'sendDefaultReaction' | 'sendEmojiInteraction' | 'sendWatchingEmojiInteraction' | 'loadMessageReactions' | - 'stopActiveReaction' | + 'stopActiveReaction' | 'startActiveReaction' | // downloads 'downloadSelectedMessages' | 'downloadMessageMedia' | 'cancelMessageMediaDownload' | // scheduled messages diff --git a/src/modules/actions/api/reactions.ts b/src/modules/actions/api/reactions.ts index 1a60d0749..b2f5c2ef9 100644 --- a/src/modules/actions/api/reactions.ts +++ b/src/modules/actions/api/reactions.ts @@ -166,6 +166,30 @@ addReducer('openChat', (global) => { }; }); +addReducer('startActiveReaction', (global, actions, payload) => { + const { messageId, reaction } = payload; + const { animationLevel } = global.settings.byKey; + + if (animationLevel !== ANIMATION_LEVEL_MAX) return global; + + if (global.activeReactions[messageId]?.reaction === reaction) { + return global; + } + + return { + ...global, + activeReactions: { + ...(reaction ? global.activeReactions : omit(global.activeReactions, [messageId])), + ...(reaction && { + [messageId]: { + reaction, + messageId, + }, + }), + }, + }; +}); + addReducer('stopActiveReaction', (global, actions, payload) => { const { messageId, reaction } = payload; diff --git a/src/modules/actions/apiUpdaters/chats.ts b/src/modules/actions/apiUpdaters/chats.ts index 2ea8a2057..503e01211 100644 --- a/src/modules/actions/apiUpdaters/chats.ts +++ b/src/modules/actions/apiUpdaters/chats.ts @@ -4,7 +4,8 @@ import { ApiUpdate, MAIN_THREAD_ID } from '../../../api/types'; import { ARCHIVED_FOLDER_ID, MAX_ACTIVE_PINNED_CHATS } from '../../../config'; import { pick } from '../../../util/iteratees'; -import { closeMessageNotifications, notifyAboutNewMessage } from '../../../util/notifications'; +import { closeMessageNotifications, notifyAboutMessage } from '../../../util/notifications'; +import { getMessageRecentReaction } from '../../helpers'; import { updateChat, updateChatListIds, @@ -120,7 +121,7 @@ addReducer('apiUpdate', (global, actions, update: ApiUpdate) => { })); } - notifyAboutNewMessage({ + notifyAboutMessage({ chat, message, }); @@ -128,6 +129,22 @@ addReducer('apiUpdate', (global, actions, update: ApiUpdate) => { break; } + case 'updateMessage': { + const { message } = update; + const chat = selectChat(global, update.chatId); + if (!chat) { + return; + } + + if (getMessageRecentReaction(message)) { + notifyAboutMessage({ + chat, + message, + }); + } + break; + } + case 'updateCommonBoxMessages': case 'updateChannelMessages': { const { ids, messageUpdate } = update; diff --git a/src/modules/actions/apiUpdaters/messages.ts b/src/modules/actions/apiUpdaters/messages.ts index 8b63df8e1..8bbbfdfd0 100644 --- a/src/modules/actions/apiUpdaters/messages.ts +++ b/src/modules/actions/apiUpdaters/messages.ts @@ -6,6 +6,8 @@ import { import { unique } from '../../../util/iteratees'; import { areDeepEqual } from '../../../util/areDeepEqual'; +import { notifyAboutMessage } from '../../../util/notifications'; +import { checkIfReactionAdded } from '../../helpers/reactions'; import { updateChat, deleteChatMessages, @@ -479,6 +481,7 @@ addReducer('apiUpdate', (global, actions, update: ApiUpdate) => { case 'updateMessageReactions': { const { chatId, id, reactions } = update; const message = selectChatMessage(global, chatId, id); + const chat = selectChat(global, update.chatId); const currentReactions = message?.reactions; // `updateMessageReactions` happens with an interval so we try to avoid redundant global state updates @@ -486,8 +489,21 @@ addReducer('apiUpdate', (global, actions, update: ApiUpdate) => { return; } - setGlobal(updateChatMessage(global, chatId, id, { reactions: update.reactions })); + // Only notify about added reactions, not removed ones + const shouldNotify = checkIfReactionAdded(currentReactions, reactions); + global = updateChatMessage(global, chatId, id, { reactions: update.reactions }); + + if (shouldNotify) { + const newMessage = selectChatMessage(global, chatId, id); + if (!chat || !newMessage) return; + notifyAboutMessage({ + chat, + message: newMessage, + }); + } + + setGlobal(global); break; } } diff --git a/src/modules/helpers/index.ts b/src/modules/helpers/index.ts index 392e61a3d..fab8385b1 100644 --- a/src/modules/helpers/index.ts +++ b/src/modules/helpers/index.ts @@ -5,3 +5,4 @@ export * from './messageSummary'; export * from './messageMedia'; export * from './localSearch'; export * from './payments'; +export { getMessageRecentReaction } from './reactions'; diff --git a/src/modules/helpers/messageSummary.ts b/src/modules/helpers/messageSummary.ts index dec555268..ca8f22df1 100644 --- a/src/modules/helpers/messageSummary.ts +++ b/src/modules/helpers/messageSummary.ts @@ -1,10 +1,11 @@ +import { ApiMessage, ApiMessageEntityTypes } from '../../api/types'; import type { TextPart } from '../../components/common/helpers/renderTextWithEntities'; +import { CONTENT_NOT_SUPPORTED } from '../../config'; import { LangFn } from '../../hooks/useLang'; -import { ApiMessage, ApiMessageEntityTypes } from '../../api/types'; -import { CONTENT_NOT_SUPPORTED } from '../../config'; -import { getMessageText } from './messages'; import trimText from '../../util/trimText'; +import { getMessageText } from './messages'; +import { getMessageRecentReaction } from './reactions'; const SPOILER_CHARS = ['⠺', '⠵', '⠞', '⠟']; export const TRUNCATED_SUMMARY_LENGTH = 80; @@ -14,11 +15,12 @@ export function getMessageSummaryText( message: ApiMessage, noEmoji = false, truncateLength = TRUNCATED_SUMMARY_LENGTH, + noReactions = true, ) { - const emoji = !noEmoji && getMessageSummaryEmoji(message); + const emoji = !noEmoji && getMessageSummaryEmoji(message, noReactions); const emojiWithSpace = emoji ? `${emoji} ` : ''; const text = trimText(getMessageTextWithSpoilers(message), truncateLength); - const description = getMessageSummaryDescription(lang, message, text); + const description = getMessageSummaryDescription(lang, message, text, noReactions); return `${emojiWithSpace}${description}`; } @@ -34,7 +36,11 @@ export function getMessageTextWithSpoilers(message: ApiMessage) { return text; } - return entities.reduce((accText, { type, offset, length }) => { + return entities.reduce((accText, { + type, + offset, + length, + }) => { if (type !== ApiMessageEntityTypes.Spoiler) { return accText; } @@ -46,9 +52,15 @@ export function getMessageTextWithSpoilers(message: ApiMessage) { }, text); } -export function getMessageSummaryEmoji(message: ApiMessage) { +export function getMessageSummaryEmoji(message: ApiMessage, noReactions = true) { const { - photo, video, audio, voice, document, sticker, poll, + photo, + video, + audio, + voice, + document, + sticker, + poll, } = message.content; if (message.groupedId || photo) { @@ -79,59 +91,86 @@ export function getMessageSummaryEmoji(message: ApiMessage) { return '📊'; } + const reaction = !noReactions && getMessageRecentReaction(message); + if (reaction) { + return reaction.reaction; + } + return undefined; } -export function getMessageSummaryDescription(lang: LangFn, message: ApiMessage, truncatedText?: string | TextPart[]) { +export function getMessageSummaryDescription( + lang: LangFn, + message: ApiMessage, + truncatedText?: string | TextPart[], + noReactions = true, +) { const { - text, photo, video, audio, voice, document, sticker, contact, poll, invoice, + text, + photo, + video, + audio, + voice, + document, + sticker, + contact, + poll, + invoice, } = message.content; + let summary: string | TextPart[] | undefined; + if (message.groupedId) { - return truncatedText || lang('lng_in_dlg_album'); + summary = truncatedText || lang('lng_in_dlg_album'); } if (photo) { - return truncatedText || lang('AttachPhoto'); + summary = truncatedText || lang('AttachPhoto'); } if (video) { - return truncatedText || lang(video.isGif ? 'AttachGif' : 'AttachVideo'); + summary = truncatedText || lang(video.isGif ? 'AttachGif' : 'AttachVideo'); } if (sticker) { - return lang('AttachSticker').trim(); + summary = lang('AttachSticker') + .trim(); } if (audio) { - return getMessageAudioCaption(message) || lang('AttachMusic'); + summary = getMessageAudioCaption(message) || lang('AttachMusic'); } if (voice) { - return truncatedText || lang('AttachAudio'); + summary = truncatedText || lang('AttachAudio'); } if (document) { - return truncatedText || document.fileName; + summary = truncatedText || document.fileName; } if (contact) { - return lang('AttachContact'); + summary = lang('AttachContact'); } if (poll) { - return poll.summary.question; + summary = poll.summary.question; } if (invoice) { - return 'Invoice'; + summary = 'Invoice'; } if (text) { - return truncatedText; + summary = truncatedText; } - return CONTENT_NOT_SUPPORTED; + const reaction = !noReactions && getMessageRecentReaction(message); + if (summary && reaction) { + summary = `to your "${summary}"`; + } + + return summary || CONTENT_NOT_SUPPORTED; } export function generateBrailleSpoiler(length: number) { @@ -142,7 +181,11 @@ export function generateBrailleSpoiler(length: number) { } function getMessageAudioCaption(message: ApiMessage) { - const { audio, text } = message.content; + const { + audio, + text, + } = message.content; - return (audio && [audio.title, audio.performer].filter(Boolean).join(' — ')) || (text?.text); + return (audio && [audio.title, audio.performer].filter(Boolean) + .join(' — ')) || (text?.text); } diff --git a/src/modules/helpers/reactions.ts b/src/modules/helpers/reactions.ts new file mode 100644 index 000000000..1f35d2d8f --- /dev/null +++ b/src/modules/helpers/reactions.ts @@ -0,0 +1,15 @@ +import { ApiMessage, ApiReactions } from '../../api/types'; + +export function getMessageRecentReaction(message: Partial) { + return message.isOutgoing ? message.reactions?.recentReactions?.[0] : undefined; +} + +export function checkIfReactionAdded(oldReactions?: ApiReactions, newReactions?: ApiReactions) { + if (!oldReactions || !oldReactions.recentReactions) return true; + if (!newReactions || !newReactions.recentReactions) return false; + const oldReactionsMap = oldReactions.results.reduce>((acc, reaction) => { + acc[reaction.reaction] = reaction.count; + return acc; + }, {}); + return newReactions.results.some(r => !oldReactionsMap[r.reaction] || oldReactionsMap[r.reaction] < r.count); +} diff --git a/src/serviceWorker/pushNotification.ts b/src/serviceWorker/pushNotification.ts index 2d40883b7..d68ff43d4 100644 --- a/src/serviceWorker/pushNotification.ts +++ b/src/serviceWorker/pushNotification.ts @@ -29,6 +29,13 @@ type NotificationData = { title: string; body: string; icon?: string; + reaction?: string; +}; + +type FocusMessageData = { + chatId?: string; + messageId?: number; + reaction?: string; }; type CloseNotificationData = { @@ -103,6 +110,7 @@ function showNotification({ body, title, icon, + reaction, }: NotificationData) { const isFirstBatch = new Date().valueOf() - lastSyncAt < 1000; const tag = String(isFirstBatch ? 0 : chatId || 0); @@ -111,6 +119,7 @@ function showNotification({ data: { chatId, messageId, + reaction, count: 1, }, icon: icon || 'icon-192x192.png', @@ -158,7 +167,7 @@ export function handlePush(e: PushEvent) { const notification = getNotificationData(data); - // Dont show already triggered notification + // Don't show already triggered notification if (shownNotifications.has(notification.messageId)) { shownNotifications.delete(notification.messageId); return; @@ -167,18 +176,11 @@ export function handlePush(e: PushEvent) { e.waitUntil(showNotification(notification)); } -async function focusChatMessage(client: WindowClient, data: { chatId?: string; messageId?: number }) { - const { - chatId, - messageId, - } = data; - if (!chatId) return; +async function focusChatMessage(client: WindowClient, data: FocusMessageData) { + if (!data.chatId) return; client.postMessage({ type: 'focusMessage', - payload: { - chatId, - messageId, - }, + payload: data, }); if (!client.focused) { // Catch "focus not allowed" DOM Exceptions @@ -240,12 +242,19 @@ export function handleClientMessage(e: ExtendableMessageEvent) { e.waitUntil(focusChatMessage(source, data)); } } - if (e.data.type === 'newMessageNotification') { + if (e.data.type === 'showMessageNotification') { // store messageId for already shown notification const notification: NotificationData = e.data.payload; - // mark this notification as shown if it was handled locally - shownNotifications.add(notification.messageId); - e.waitUntil(showNotification(notification)); + e.waitUntil((async () => { + // Close existing notification if it is already shown + if (notification.chatId) { + const notifications = await self.registration.getNotifications({ tag: notification.chatId }); + notifications.forEach((n) => n.close()); + } + // Mark this notification as shown if it was handled locally + shownNotifications.add(notification.messageId); + return showNotification(notification); + })()); } if (e.data.type === 'closeMessageNotifications') { diff --git a/src/util/notifications.ts b/src/util/notifications.ts index d83faf61b..58571c3c8 100644 --- a/src/util/notifications.ts +++ b/src/util/notifications.ts @@ -1,7 +1,5 @@ import { callApi } from '../api/gramjs'; -import { - ApiChat, ApiMediaFormat, ApiMessage, ApiUser, -} from '../api/types'; +import { ApiChat, ApiMediaFormat, ApiMessage, ApiUser, ApiUserReaction } from '../api/types'; import { renderActionMessageText } from '../components/common/helpers/renderActionMessageText'; import { DEBUG, IS_TEST } from '../config'; import { getDispatch, getGlobal, setGlobal } from '../lib/teact/teactn'; @@ -9,19 +7,25 @@ import { getChatAvatarHash, getChatTitle, getMessageAction, + getMessageRecentReaction, getMessageSenderName, getMessageSummaryText, getPrivateChatUserId, isActionMessage, isChatChannel, - selectIsChatMuted, selectShouldShowMessagePreview, + selectIsChatMuted, + selectShouldShowMessagePreview, } from '../modules/helpers'; -import { getTranslation } from './langProvider'; import { addNotifyExceptions, replaceSettings } from '../modules/reducers'; import { - selectChatMessage, selectNotifyExceptions, selectNotifySettings, selectUser, + selectChatMessage, + selectCurrentMessageList, + selectNotifyExceptions, + selectNotifySettings, + selectUser, } from '../modules/selectors'; -import { IS_SERVICE_WORKER_SUPPORTED } from './environment'; +import { IS_SERVICE_WORKER_SUPPORTED, IS_TOUCH_ENV } from './environment'; +import { getTranslation } from './langProvider'; import * as mediaLoader from './mediaLoader'; import { debounce } from './schedulers'; @@ -246,16 +250,25 @@ function checkIfShouldNotify(chat: ApiChat) { if (isMuted || chat.isNotJoined || !chat.isListed) { return false; } - + // On touch devices show notifications when chat is not active + if (IS_TOUCH_ENV) { + const { + chatId, + type, + } = selectCurrentMessageList(global) || {}; + return !(chatId === chat.id && type === 'thread'); + } + // On desktop show notifications when window is not focused return !document.hasFocus(); } -function getNotificationContent(chat: ApiChat, message: ApiMessage) { +function getNotificationContent(chat: ApiChat, message: ApiMessage, reaction?: ApiUserReaction) { const global = getGlobal(); - const { + let { senderId, replyToMessageId, } = message; + if (reaction) senderId = reaction.userId; const messageSender = senderId ? selectUser(global, senderId) : undefined; const messageAction = getMessageAction(message as ApiMessage); @@ -291,7 +304,7 @@ function getNotificationContent(chat: ApiChat, message: ApiMessage) { ) as string; } else { const senderName = getMessageSenderName(getTranslation, chat.id, messageSender); - const summary = getMessageSummaryText(getTranslation, message); + const summary = getMessageSummaryText(getTranslation, message, false, 60, false); body = senderName ? `${senderName}: ${summary}` : summary; } @@ -316,7 +329,7 @@ async function getAvatar(chat: ApiChat) { return mediaData; } -export async function notifyAboutNewMessage({ +export async function notifyAboutMessage({ chat, message, }: { chat: ApiChat; message: Partial }) { @@ -329,26 +342,29 @@ export async function notifyAboutNewMessage({ return; } if (!areNotificationsSupported) return; + if (!message.id) return; + const activeReaction = getMessageRecentReaction(message); + const icon = await getAvatar(chat); + const { title, body, - } = getNotificationContent(chat, message as ApiMessage); - - const icon = await getAvatar(chat); + } = getNotificationContent(chat, message as ApiMessage, activeReaction); if (checkIfPushSupported()) { if (navigator.serviceWorker?.controller) { // notify service worker about new message notification navigator.serviceWorker.controller.postMessage({ - type: 'newMessageNotification', + type: 'showMessageNotification', payload: { title, body, icon, chatId: chat.id, messageId: message.id, + reaction: activeReaction ? activeReaction.reaction : undefined, }, }); } @@ -373,6 +389,12 @@ export async function notifyAboutNewMessage({ chatId: chat.id, messageId: message.id, }); + if (activeReaction) { + dispatch.startActiveReaction({ + messageId: message.id, + reaction: activeReaction.reaction, + }); + } if (window.focus) { window.focus(); } diff --git a/src/util/setupServiceWorker.ts b/src/util/setupServiceWorker.ts index 85789328c..509d1638d 100644 --- a/src/util/setupServiceWorker.ts +++ b/src/util/setupServiceWorker.ts @@ -16,10 +16,17 @@ function handleWorkerMessage(e: MessageEvent) { } if (!action.type) return; const dispatch = getDispatch(); + const payload = action.payload; switch (action.type) { case 'focusMessage': if (dispatch.focusMessage) { - dispatch.focusMessage(action.payload); + dispatch.focusMessage(payload); + } + if (dispatch.startActiveReaction && payload.reaction) { + dispatch.startActiveReaction({ + messageId: payload.messageId, + reaction: payload.reaction, + }); } break; case 'playNotificationSound':