Notifications: Support reactions (#1709)
This commit is contained in:
parent
611ec32ee8
commit
e701bf9836
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,3 +5,4 @@ export * from './messageSummary';
|
||||
export * from './messageMedia';
|
||||
export * from './localSearch';
|
||||
export * from './payments';
|
||||
export { getMessageRecentReaction } from './reactions';
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
15
src/modules/helpers/reactions.ts
Normal file
15
src/modules/helpers/reactions.ts
Normal 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);
|
||||
}
|
||||
@ -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') {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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':
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user