refactor: 重构消息通知模式,将免打扰相关的逻辑集成到核心代码中

避免怪异的用户体验
pull/70/head
moonrailgun 2 years ago
parent 184daa3e73
commit 66c1038970

@ -47,6 +47,16 @@ export interface SharedEventMap {
*/
readMessage: (payload: ChatMessage | null) => void;
/**
* ()
*/
receiveMessage: (payload: ChatMessage) => void;
/**
*
*/
receiveUnmutedMessage: (payload: ChatMessage) => void;
/**
*
*/

@ -0,0 +1,23 @@
import { sharedEvent, useSharedEventHandler } from '../../event';
import { useUserNotifyMute } from './useUserSettings';
/**
*
*
*
* {receiveMessage} -> -> {receiveUnmutedMessage}
* -> ,
*/
export function useMessageNotifyEventFilter() {
const { checkIsMuted } = useUserNotifyMute();
useSharedEventHandler('receiveMessage', (payload) => {
if (!payload) {
return;
}
if (!checkIsMuted(payload.converseId, payload.groupId)) {
sharedEvent.emit('receiveUnmutedMessage', payload);
}
});
}

@ -7,6 +7,8 @@ import {
UserSettings,
} from '../../model/user';
import { useAsyncRequest } from '../useAsyncRequest';
import { useMemoizedFn } from '../useMemoizedFn';
import _without from 'lodash/without';
/**
* hooks
@ -17,7 +19,7 @@ export function useUserSettings() {
[CacheKey],
() => getUserSettings(),
{
staleTime: 1 * 60 * 1000, // 缓存1分钟
staleTime: 10 * 60 * 1000, // 缓存10分钟
}
);
@ -56,3 +58,48 @@ export function useSingleUserSetting<K extends keyof UserSettings>(
loading,
};
}
/**
*
*/
export function useUserNotifyMute() {
const { value: list = [], setValue: setList } = useSingleUserSetting(
'messageNotificationMuteList',
[]
);
const mute = useMemoizedFn((converseOrGroupId: string) => {
setList([...list, converseOrGroupId]);
});
const unmute = useMemoizedFn((converseOrGroupId: string) => {
setList(_without(list, converseOrGroupId));
});
const toggleMute = useMemoizedFn((converseOrGroupId) => {
if (list.includes(converseOrGroupId)) {
unmute(converseOrGroupId);
} else {
mute(converseOrGroupId);
}
});
/**
*
*/
const checkIsMuted = useMemoizedFn((panelId: string, groupId?: string) => {
if (groupId) {
return list.includes(panelId) || list.includes(groupId);
}
return list.includes(panelId);
});
return {
mutedList: list,
mute,
unmute,
toggleMute,
checkIsMuted,
};
}

@ -54,11 +54,13 @@ export { useLanguage } from './i18n/language';
// hooks
export { createUseStorageState } from './hooks/factory/createUseStorageState';
export { useAvailableServices } from './hooks/model/useAvailableServices';
export { useMessageNotifyEventFilter } from './hooks/model/useMessageNotifyEventFilter';
export { useUserInfoList } from './hooks/model/useUserInfoList';
export { useUsernames } from './hooks/model/useUsernames';
export {
useUserSettings,
useSingleUserSetting,
useUserNotifyMute,
} from './hooks/model/useUserSettings';
export { useAlphaMode } from './hooks/useAlphaMode';
export { useAsync } from './hooks/useAsync';

@ -32,6 +32,11 @@ export interface UserSettings {
*/
messageListVirtualization?: boolean;
/**
* ()
*/
messageNotificationMuteList?: string[];
/**
*
*/

@ -200,6 +200,8 @@ function listenNotify(socket: AppSocket, store: AppStore) {
])
);
}
sharedEvent.emit('receiveMessage', message); // 推送到通知中心
});
socket.listen<ChatMessage>('chat.message.update', (message) => {

@ -1,16 +1,7 @@
import {
regGroupPanelBadge,
regPluginGroupTextPanelExtraMenu,
regPluginSettings,
sharedEvent,
showToasts,
} from '@capital/common';
import { Icon } from '@capital/component';
import React from 'react';
import { appendSilent, hasSilent, removeSilent } from './silent';
import { regPluginSettings, showToasts } from '@capital/common';
import { initNotify } from './notify';
import { Translate } from './translate';
import { PLUGIN_NAME, PLUGIN_SYSTEM_SETTINGS_DISABLED_SOUND } from './const';
import { PLUGIN_SYSTEM_SETTINGS_DISABLED_SOUND } from './const';
if ('Notification' in window) {
initNotify();
@ -19,28 +10,6 @@ if ('Notification' in window) {
console.warn(Translate.nosupport);
}
regPluginGroupTextPanelExtraMenu({
name: `${PLUGIN_NAME}/grouppanelmenu`,
label: Translate.slient,
icon: 'mdi:bell-off-outline',
onClick: (panelInfo) => {
if (hasSilent(panelInfo.id)) {
removeSilent(panelInfo.id);
} else {
appendSilent(panelInfo.id);
}
sharedEvent.emit('groupPanelBadgeUpdate');
},
});
regGroupPanelBadge({
name: `${PLUGIN_NAME}/grouppanelbadge`,
render: (groupId: string, panelId: string) => {
return hasSilent(panelId) ? <Icon icon="mdi:bell-off-outline" /> : null;
},
});
regPluginSettings({
name: PLUGIN_SYSTEM_SETTINGS_DISABLED_SOUND,
label: Translate.disabledSound,

@ -1,5 +1,4 @@
import {
regSocketEventListener,
getGlobalState,
getCachedUserInfo,
getCachedBaseGroupInfo,
@ -9,7 +8,6 @@ import {
getCachedUserSettings,
} from '@capital/common';
import { Translate } from './translate';
import { hasSilent } from './silent';
import { incBubble, setBubble } from './bubble';
import _get from 'lodash/get';
import { PLUGIN_SYSTEM_SETTINGS_DISABLED_SOUND } from './const';
@ -39,67 +37,58 @@ export function initNotify() {
});
window.addEventListener('blur', () => (isBlur = true));
regSocketEventListener({
eventName: 'chat.message.add',
eventFn: (message) => {
const converseId = message.converseId;
const currentUserId = getGlobalState()?.user.info._id;
sharedEvent.on('receiveUnmutedMessage', (message) => {
const currentUserId = getGlobalState()?.user.info._id;
if (hasSilent(converseId)) {
// 手动设置了当前会话免打扰
return;
}
if (currentUserId === message.author) {
// 忽略本人消息
return;
}
if (currentUserId === message.author) {
// 忽略本人消息
return;
}
const hidden = window.document.hidden ?? false;
if (hidden || isBlur) {
// 如果当前不是活跃窗口或处于隐藏状态,则创建通知
if (Notification.permission === 'granted') {
// TODO: 需要增加显示所在群组
Promise.all([
getCachedUserInfo(message.author),
message.groupId
? getCachedBaseGroupInfo(message.groupId).then((d) => d.name)
: Promise.resolve(Translate.dm),
]).then(([userInfo, scopeName]) => {
const nickname = userInfo?.nickname ?? '';
const icon = userInfo?.avatar ?? undefined;
const content = message.content;
const title = `${Translate.from} [${scopeName}] ${nickname}`;
const options: NotificationOptions = {
body: content,
icon,
tag: TAG,
renotify: true,
data: message,
silent: true, // 因为有提示音了,所以禁音默认音
const hidden = window.document.hidden ?? false;
if (hidden || isBlur) {
// 如果当前不是活跃窗口或处于隐藏状态,则创建通知
if (Notification.permission === 'granted') {
// TODO: 需要增加显示所在群组
Promise.all([
getCachedUserInfo(message.author),
message.groupId
? getCachedBaseGroupInfo(message.groupId).then((d) => d.name)
: Promise.resolve(Translate.dm),
]).then(([userInfo, scopeName]) => {
const nickname = userInfo?.nickname ?? '';
const icon = userInfo?.avatar ?? undefined;
const content = message.content;
const title = `${Translate.from} [${scopeName}] ${nickname}`;
const options: NotificationOptions = {
body: content,
icon,
tag: TAG,
renotify: true,
data: message,
silent: true, // 因为有提示音了,所以禁音默认音
};
if (registration && registration.showNotification) {
registration.showNotification(title, options);
} else {
// fallback
const notification = new Notification(title, options);
notification.onclick = (e: any) => {
const tag = e.target.tag;
const data = e.target.data;
handleMessageNotifyClick(tag, data);
};
if (registration && registration.showNotification) {
registration.showNotification(title, options);
} else {
// fallback
const notification = new Notification(title, options);
notification.onclick = (e: any) => {
const tag = e.target.tag;
const data = e.target.data;
handleMessageNotifyClick(tag, data);
};
}
});
}
incBubble();
}
});
}
tryPlayNotificationSound(); // 不管当前是不是处于活跃状态,都发出提示音
},
incBubble();
}
tryPlayNotificationSound(); // 不管当前是不是处于活跃状态,都发出提示音
});
}

@ -1,39 +0,0 @@
/**
*
*/
const KEY = 'plugin:com.msgbyte.notify/slientStorage';
const silentSet = new Set<string>(loadFromLocalstorage());
export function appendSilent(converseId: string) {
silentSet.add(converseId);
saveToLocalstorage();
}
export function removeSilent(converseId: string) {
silentSet.delete(converseId);
saveToLocalstorage();
}
export function hasSilent(converseId: string): boolean {
return silentSet.has(converseId);
}
function saveToLocalstorage() {
localStorage.setItem(KEY, JSON.stringify(Array.from(silentSet)));
}
function loadFromLocalstorage(): string[] {
try {
const arr = JSON.parse(localStorage.getItem(KEY));
if (Array.isArray(arr)) {
return arr;
} else {
return [];
}
} catch (err) {
console.error(err);
return [];
}
}

@ -1,4 +1,4 @@
import { Badge, Space, Typography } from 'antd';
import { Badge, BadgeProps, Space, Typography } from 'antd';
import clsx from 'clsx';
import React from 'react';
import { useLocation } from 'react-router';
@ -12,10 +12,12 @@ export const GroupPanelItem: React.FC<{
name: string;
icon: React.ReactNode;
to: string;
dimmed?: boolean; // 颜色暗淡
badge?: boolean;
badgeProps?: BadgeProps;
extraBadge?: React.ReactNode[];
}> = React.memo((props) => {
const { icon, name, to, badge } = props;
const { icon, name, to, dimmed = false, badge } = props;
const location = useLocation();
const isActive = location.pathname.startsWith(to);
@ -26,24 +28,26 @@ export const GroupPanelItem: React.FC<{
'w-full hover:bg-black hover:bg-opacity-20 dark:hover:bg-white dark:hover:bg-opacity-20 cursor-pointer text-gray-900 dark:text-white rounded px-1 h-8 flex items-center text-base group',
{
'bg-black bg-opacity-20 dark:bg-white dark:bg-opacity-20': isActive,
}
},
dimmed && 'text-opacity-40 dark:text-opacity-40'
)}
>
<div className="flex items-center justify-center px-1 mr-1">{icon}</div>
<div className={clsx('flex items-center justify-center px-1 mr-1')}>
{icon}
</div>
<Typography.Text
className="flex-1 text-gray-900 dark:text-white"
className={clsx(
'flex-1 text-gray-900 dark:text-white',
dimmed && 'text-opacity-40 dark:text-opacity-40'
)}
ellipsis={true}
>
{name}
</Typography.Text>
<Space>
{badge === true ? (
<Badge status="error" />
) : (
<Badge count={Number(badge) || 0} />
)}
{badge === true && <Badge status="error" {...props.badgeProps} />}
{props.extraBadge}
</Space>

@ -9,10 +9,11 @@ import {
useAppDispatch,
useConverseAck,
useGroupInfo,
useUserNotifyMute,
} from 'tailchat-shared';
import { GroupPanelItem } from '@/components/GroupPanelItem';
import { GroupTextPanelItem } from './TextPanelItem';
import { Dropdown, Menu, MenuProps } from 'antd';
import { Dropdown, MenuProps } from 'antd';
import copy from 'copy-to-clipboard';
import { usePanelWindow } from '@/hooks/usePanelWindow';
import { LoadingSpinner } from '@/components/LoadingSpinner';
@ -37,6 +38,7 @@ export const SidebarItem: React.FC<{
const { markConverseAllAck } = useConverseAck(panelId);
const extraMenuItems = useExtraMenuItems(panel);
const extraBadge = useGroupPanelExtraBadge(groupId, panelId);
const { checkIsMuted, toggleMute } = useUserNotifyMute();
if (!groupInfo) {
return <LoadingSpinner />;
@ -95,6 +97,12 @@ export const SidebarItem: React.FC<{
icon: <Icon icon="mdi:message-badge-outline" />,
onClick: markConverseAllAck,
},
panel.type === GroupPanelType.TEXT && {
key: 'mute',
label: checkIsMuted(panelId, groupId) ? t('取消免打扰') : t('免打扰'),
icon: <Icon icon="mdi:bell-off-outline" />,
onClick: () => toggleMute(panelId),
},
...extraMenuItems,
]),
};

@ -1,6 +1,10 @@
import { GroupPanelItem } from '@/components/GroupPanelItem';
import React from 'react';
import { GroupPanel, useGroupTextPanelUnread } from 'tailchat-shared';
import React, { useMemo } from 'react';
import {
GroupPanel,
useGroupTextPanelUnread,
useUserNotifyMute,
} from 'tailchat-shared';
import { useGroupPanelExtraBadge } from './utils';
interface GroupTextPanelItemProps {
@ -18,13 +22,22 @@ export const GroupTextPanelItem: React.FC<GroupTextPanelItemProps> = React.memo(
const panelId = panel.id;
const hasUnread = useGroupTextPanelUnread(panelId);
const extraBadge = useGroupPanelExtraBadge(groupId, panelId);
const { checkIsMuted } = useUserNotifyMute();
const isMuted = useMemo(
() => checkIsMuted(panelId, groupId),
[groupId, panelId]
);
return (
<GroupPanelItem
name={panel.name}
icon={props.icon}
to={`/main/group/${groupId}/${panel.id}`}
dimmed={isMuted}
badge={hasUnread}
badgeProps={{
status: isMuted ? 'default' : 'error',
}}
extraBadge={extraBadge}
/>
);

@ -1,6 +1,7 @@
import { GlobalTemporaryTip } from '@/components/GlobalTemporaryTip';
import { useRecordMeasure } from '@/utils/measure-helper';
import React from 'react';
import { useMessageNotifyEventFilter } from 'tailchat-shared';
import { MainContent } from './Content';
import { Navbar } from './Navbar';
import { MainProvider } from './Provider';
@ -9,6 +10,7 @@ import { useShortcuts } from './useShortcuts';
const MainRoute: React.FC = React.memo(() => {
useRecordMeasure('appMainRenderStart');
useShortcuts();
useMessageNotifyEventFilter();
return (
<MainProvider>

Loading…
Cancel
Save