Notifications: Support reactions (#1709)

This commit is contained in:
Alexander Zinchuk 2022-02-20 13:39:27 +02:00
parent 611ec32ee8
commit e701bf9836
10 changed files with 214 additions and 60 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -5,3 +5,4 @@ export * from './messageSummary';
export * from './messageMedia';
export * from './localSearch';
export * from './payments';
export { getMessageRecentReaction } from './reactions';

View File

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

View File

@ -0,0 +1,15 @@
import { ApiMessage, ApiReactions } from '../../api/types';
export function getMessageRecentReaction(message: Partial<ApiMessage>) {
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<Record<string, number>>((acc, reaction) => {
acc[reaction.reaction] = reaction.count;
return acc;
}, {});
return newReactions.results.some(r => !oldReactionsMap[r.reaction] || oldReactionsMap[r.reaction] < r.count);
}

View File

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

View File

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

View File

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