Notifications: Add web settings, sound and avatar, some fixes (#1317)

This commit is contained in:
Alexander Zinchuk 2021-08-11 01:27:52 +03:00
parent dd58fc08df
commit d59390364c
13 changed files with 235 additions and 41 deletions

BIN
public/notification.mp3 Normal file

Binary file not shown.

View File

@ -136,6 +136,10 @@
}
}
&-slider {
margin-top: 2rem;
}
&-description {
font-size: 0.875rem;
color: var(--color-text-secondary);

View File

@ -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<GlobalActions, (
'loadNotificationSettings' | 'updateContactSignUpNotification' | 'updateNotificationSettings'
'loadNotificationSettings' | 'updateContactSignUpNotification' |
'updateNotificationSettings' | 'updateWebNotificationSettings'
)>;
const SettingsNotifications: FC<OwnProps & StateProps & DispatchProps> = ({
@ -44,9 +49,13 @@ const SettingsNotifications: FC<OwnProps & StateProps & DispatchProps> = ({
hasBroadcastNotifications,
hasBroadcastMessagePreview,
hasContactJoinedNotifications,
hasPushNotifications,
hasWebNotifications,
notificationSoundVolume,
loadNotificationSettings,
updateContactSignUpNotification,
updateNotificationSettings,
updateWebNotificationSettings,
}) => {
useEffect(() => {
loadNotificationSettings();
@ -88,6 +97,44 @@ const SettingsNotifications: FC<OwnProps & StateProps & DispatchProps> = ({
return (
<div className="settings-content custom-scroll">
<div className="settings-item">
<h4 className="settings-item-header" dir={lang.isRtl ? 'rtl' : undefined}>
Web notifications
</h4>
<Checkbox
label="Web notifications"
// eslint-disable-next-line max-len
subLabel={lang(hasWebNotifications ? 'UserInfo.NotificationsEnabled' : 'UserInfo.NotificationsDisabled')}
checked={hasWebNotifications}
onChange={(e) => {
updateWebNotificationSettings({ hasWebNotifications: e.target.checked });
}}
/>
<Checkbox
label="Offline notifications"
disabled={!hasWebNotifications}
// eslint-disable-next-line max-len
subLabel={lang(hasPushNotifications ? 'UserInfo.NotificationsEnabled' : 'UserInfo.NotificationsDisabled')}
checked={hasPushNotifications}
onChange={(e) => {
updateWebNotificationSettings({ hasPushNotifications: e.target.checked });
}}
/>
<div className="settings-item-slider">
<RangeSlider
label="Sound"
disabled={!hasWebNotifications}
range={{
min: 0,
max: 10,
}}
value={notificationSoundVolume}
onChange={(volume) => {
updateWebNotificationSettings({ notificationSoundVolume: volume });
}}
/>
</div>
</div>
<div className="settings-item">
<h4 className="settings-item-header" dir={lang.isRtl ? 'rtl' : undefined}>
{lang('AutodownloadPrivateChats')}
@ -102,6 +149,7 @@ const SettingsNotifications: FC<OwnProps & StateProps & DispatchProps> = ({
/>
<Checkbox
label={lang('MessagePreview')}
disabled={!hasPrivateChatsNotifications}
// eslint-disable-next-line max-len
subLabel={lang(hasPrivateChatsMessagePreview ? 'UserInfo.NotificationsEnabled' : 'UserInfo.NotificationsDisabled')}
checked={hasPrivateChatsMessagePreview}
@ -120,6 +168,7 @@ const SettingsNotifications: FC<OwnProps & StateProps & DispatchProps> = ({
/>
<Checkbox
label={lang('MessagePreview')}
disabled={!hasGroupNotifications}
subLabel={lang(hasGroupMessagePreview ? 'UserInfo.NotificationsEnabled' : 'UserInfo.NotificationsDisabled')}
checked={hasGroupMessagePreview}
onChange={(e) => { handleSettingsChange(e, 'group', 'showPreviews'); }}
@ -138,6 +187,7 @@ const SettingsNotifications: FC<OwnProps & StateProps & DispatchProps> = ({
/>
<Checkbox
label={lang('MessagePreview')}
disabled={!hasBroadcastNotifications}
// eslint-disable-next-line max-len
subLabel={lang(hasBroadcastMessagePreview ? 'UserInfo.NotificationsEnabled' : 'UserInfo.NotificationsDisabled')}
checked={hasBroadcastMessagePreview}
@ -167,10 +217,14 @@ export default memo(withGlobal<OwnProps>((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));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -225,9 +225,14 @@ export function isChatArchived(chat: ApiChat) {
}
export function selectIsChatMuted(
chat: ApiChat, notifySettings: NotifySettings, notifyExceptions?: Record<number, NotifyException>,
chat: ApiChat, notifySettings: NotifySettings, notifyExceptions: Record<number, NotifyException> = [],
) {
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<number, NotifyException> = [],
) {
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);
}

View File

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

View File

@ -38,6 +38,9 @@ export type NotifySettings = {
hasBroadcastNotifications?: boolean;
hasBroadcastMessagePreview?: boolean;
hasContactJoinedNotifications?: boolean;
hasWebNotifications: boolean;
hasPushNotifications: boolean;
notificationSoundVolume: number;
};
export type LangCode = (

View File

@ -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<number>();
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<ApiUser>(Boolean as any)
? actionTargetUserIds.map((userId) => selectUser(global, userId))
.filter<ApiUser>(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<ApiMediaFormat.BlobUrl>(imageHash);
if (!mediaData) {
await mediaLoader.fetch(imageHash, ApiMediaFormat.BlobUrl);
mediaData = mediaLoader.getFromMemory<ApiMediaFormat.BlobUrl>(imageHash);
}
return mediaData;
}
export async function showNewMessageNotification({
chat,
message,
isActiveChat,
}: { chat: ApiChat; message: Partial<ApiMessage>; isActiveChat: boolean}) {
}: { chat: ApiChat; message: Partial<ApiMessage>; 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);
};
}
}

View File

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