diff --git a/client/shared/event/index.ts b/client/shared/event/index.ts index 257f79dd..41181df0 100644 --- a/client/shared/event/index.ts +++ b/client/shared/event/index.ts @@ -47,6 +47,16 @@ export interface SharedEventMap { */ readMessage: (payload: ChatMessage | null) => void; + /** + * 接受到消息(所有的) + */ + receiveMessage: (payload: ChatMessage) => void; + + /** + * 接受到未被静音的消息 + */ + receiveUnmutedMessage: (payload: ChatMessage) => void; + /** * 群组面板状态更新 */ diff --git a/client/shared/hooks/model/useMessageNotifyEventFilter.tsx b/client/shared/hooks/model/useMessageNotifyEventFilter.tsx new file mode 100644 index 00000000..f5c58973 --- /dev/null +++ b/client/shared/hooks/model/useMessageNotifyEventFilter.tsx @@ -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); + } + }); +} diff --git a/client/shared/hooks/model/useUserSettings.ts b/client/shared/hooks/model/useUserSettings.ts index ba134cf5..2ca75aee 100644 --- a/client/shared/hooks/model/useUserSettings.ts +++ b/client/shared/hooks/model/useUserSettings.ts @@ -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( 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, + }; +} diff --git a/client/shared/index.tsx b/client/shared/index.tsx index a2cc80a0..4f9a70d2 100644 --- a/client/shared/index.tsx +++ b/client/shared/index.tsx @@ -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'; diff --git a/client/shared/model/user.ts b/client/shared/model/user.ts index c0d8ff33..0ee13327 100644 --- a/client/shared/model/user.ts +++ b/client/shared/model/user.ts @@ -32,6 +32,11 @@ export interface UserSettings { */ messageListVirtualization?: boolean; + /** + * 消息通知免打扰(静音) + */ + messageNotificationMuteList?: string[]; + /** * 其他的设置项 */ diff --git a/client/shared/redux/setup.ts b/client/shared/redux/setup.ts index 90cb646c..461b64ba 100644 --- a/client/shared/redux/setup.ts +++ b/client/shared/redux/setup.ts @@ -200,6 +200,8 @@ function listenNotify(socket: AppSocket, store: AppStore) { ]) ); } + + sharedEvent.emit('receiveMessage', message); // 推送到通知中心 }); socket.listen('chat.message.update', (message) => { diff --git a/client/web/plugins/com.msgbyte.notify/src/index.tsx b/client/web/plugins/com.msgbyte.notify/src/index.tsx index a80cba44..6ac91997 100644 --- a/client/web/plugins/com.msgbyte.notify/src/index.tsx +++ b/client/web/plugins/com.msgbyte.notify/src/index.tsx @@ -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) ? : null; - }, -}); - regPluginSettings({ name: PLUGIN_SYSTEM_SETTINGS_DISABLED_SOUND, label: Translate.disabledSound, diff --git a/client/web/plugins/com.msgbyte.notify/src/notify.ts b/client/web/plugins/com.msgbyte.notify/src/notify.ts index f0541333..8eb22b27 100644 --- a/client/web/plugins/com.msgbyte.notify/src/notify.ts +++ b/client/web/plugins/com.msgbyte.notify/src/notify.ts @@ -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(); // 不管当前是不是处于活跃状态,都发出提示音 }); } diff --git a/client/web/plugins/com.msgbyte.notify/src/silent.ts b/client/web/plugins/com.msgbyte.notify/src/silent.ts deleted file mode 100644 index bf29faee..00000000 --- a/client/web/plugins/com.msgbyte.notify/src/silent.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * 免打扰 - */ - -const KEY = 'plugin:com.msgbyte.notify/slientStorage'; - -const silentSet = new Set(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 []; - } -} diff --git a/client/web/src/components/GroupPanelItem.tsx b/client/web/src/components/GroupPanelItem.tsx index 58074a46..e1ad755e 100644 --- a/client/web/src/components/GroupPanelItem.tsx +++ b/client/web/src/components/GroupPanelItem.tsx @@ -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' )} > -
{icon}
+
+ {icon} +
{name} - {badge === true ? ( - - ) : ( - - )} + {badge === true && } {props.extraBadge} diff --git a/client/web/src/routes/Main/Content/Group/SidebarItem.tsx b/client/web/src/routes/Main/Content/Group/SidebarItem.tsx index 2b7fc892..35efa371 100644 --- a/client/web/src/routes/Main/Content/Group/SidebarItem.tsx +++ b/client/web/src/routes/Main/Content/Group/SidebarItem.tsx @@ -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 ; @@ -95,6 +97,12 @@ export const SidebarItem: React.FC<{ icon: , onClick: markConverseAllAck, }, + panel.type === GroupPanelType.TEXT && { + key: 'mute', + label: checkIsMuted(panelId, groupId) ? t('取消免打扰') : t('免打扰'), + icon: , + onClick: () => toggleMute(panelId), + }, ...extraMenuItems, ]), }; diff --git a/client/web/src/routes/Main/Content/Group/TextPanelItem.tsx b/client/web/src/routes/Main/Content/Group/TextPanelItem.tsx index 5c82fd6b..c28c66ab 100644 --- a/client/web/src/routes/Main/Content/Group/TextPanelItem.tsx +++ b/client/web/src/routes/Main/Content/Group/TextPanelItem.tsx @@ -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 = 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 ( ); diff --git a/client/web/src/routes/Main/index.tsx b/client/web/src/routes/Main/index.tsx index e0f5c1f7..eda26e81 100644 --- a/client/web/src/routes/Main/index.tsx +++ b/client/web/src/routes/Main/index.tsx @@ -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 (