diff --git a/client/shared/event/index.ts b/client/shared/event/index.ts
index 4e4c0d4c..e5ff0b4b 100644
--- a/client/shared/event/index.ts
+++ b/client/shared/event/index.ts
@@ -35,6 +35,11 @@ export interface SharedEventMap {
* 消息已读(消息出现在界面上)
*/
readMessage: (payload: ChatMessage | null) => void;
+
+ /**
+ * 群组面板状态更新
+ */
+ groupPanelBadgeUpdate: () => void;
}
export type SharedEventType = keyof SharedEventMap;
diff --git a/client/shared/hooks/useWatch.ts b/client/shared/hooks/useWatch.ts
new file mode 100644
index 00000000..10346f79
--- /dev/null
+++ b/client/shared/hooks/useWatch.ts
@@ -0,0 +1,12 @@
+import { DependencyList, useLayoutEffect } from 'react';
+import { useMemoizedFn } from './useMemoizedFn';
+
+/**
+ * 监听变更并触发回调
+ */
+export function useWatch(deps: DependencyList, cb: () => void) {
+ const memoizedFn = useMemoizedFn(cb);
+ useLayoutEffect(() => {
+ memoizedFn();
+ }, deps);
+}
diff --git a/client/shared/index.tsx b/client/shared/index.tsx
index acae8170..192e05fe 100644
--- a/client/shared/index.tsx
+++ b/client/shared/index.tsx
@@ -69,6 +69,7 @@ export { useRafState } from './hooks/useRafState';
export { useSearch } from './hooks/useSearch';
export { useShallowObject } from './hooks/useShallowObject';
export { useUpdateRef } from './hooks/useUpdateRef';
+export { useWatch } from './hooks/useWatch';
export { useWhyDidYouUpdate } from './hooks/useWhyDidYouUpdate';
// manager
diff --git a/client/web/package.json b/client/web/package.json
index bbf93cd3..0d043d88 100644
--- a/client/web/package.json
+++ b/client/web/package.json
@@ -24,6 +24,7 @@
},
"dependencies": {
"@loadable/component": "^5.15.2",
+ "ahooks": "^3.7.1",
"antd": "^4.19.5",
"axios": "^0.21.1",
"clsx": "^1.1.1",
diff --git a/client/web/plugins/com.msgbyte.intro/src/tour.ts b/client/web/plugins/com.msgbyte.intro/src/tour.ts
index 10c0c4e0..794d2312 100644
--- a/client/web/plugins/com.msgbyte.intro/src/tour.ts
+++ b/client/web/plugins/com.msgbyte.intro/src/tour.ts
@@ -2,7 +2,7 @@ import Shepherd from 'shepherd.js';
import { steps } from './steps';
import './style.less';
-const KEY = 'com.msgbyte.intro/hasRun';
+const KEY = 'plugin:com.msgbyte.intro/hasRun';
if (!window.localStorage.getItem(KEY)) {
const tour = new Shepherd.Tour({
diff --git a/client/web/plugins/com.msgbyte.notify/package.json b/client/web/plugins/com.msgbyte.notify/package.json
index c09958fd..19abc0cb 100644
--- a/client/web/plugins/com.msgbyte.notify/package.json
+++ b/client/web/plugins/com.msgbyte.notify/package.json
@@ -1,6 +1,6 @@
{
"name": "@plugins/com.msgbyte.notify",
- "main": "src/index.ts",
+ "main": "src/index.tsx",
"version": "0.0.0",
"private": true,
"dependencies": {}
diff --git a/client/web/plugins/com.msgbyte.notify/src/index.ts b/client/web/plugins/com.msgbyte.notify/src/index.ts
deleted file mode 100644
index 6824ed1e..00000000
--- a/client/web/plugins/com.msgbyte.notify/src/index.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { initNotify } from './notify';
-
-if ('Notification' in window) {
- initNotify();
-} else {
- console.warn('浏览器不支持 Notification');
-}
diff --git a/client/web/plugins/com.msgbyte.notify/src/index.tsx b/client/web/plugins/com.msgbyte.notify/src/index.tsx
new file mode 100644
index 00000000..84821359
--- /dev/null
+++ b/client/web/plugins/com.msgbyte.notify/src/index.tsx
@@ -0,0 +1,39 @@
+import {
+ regGroupPanelBadge,
+ regPluginGroupTextPanelExtraMenu,
+ sharedEvent,
+} from '@capital/common';
+import { Icon } from '@capital/component';
+import React from 'react';
+import { appendSilent, hasSilent, removeSilent } from './silent';
+import { initNotify } from './notify';
+
+const PLUGIN_NAME = 'com.msgbyte.notify';
+
+if ('Notification' in window) {
+ initNotify();
+} else {
+ console.warn('浏览器不支持 Notification');
+}
+
+regPluginGroupTextPanelExtraMenu({
+ name: `${PLUGIN_NAME}/grouppanelmenu`,
+ label: '免打扰',
+ 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;
+ },
+});
diff --git a/client/web/plugins/com.msgbyte.notify/src/notify.ts b/client/web/plugins/com.msgbyte.notify/src/notify.ts
index 179ada56..048d1392 100644
--- a/client/web/plugins/com.msgbyte.notify/src/notify.ts
+++ b/client/web/plugins/com.msgbyte.notify/src/notify.ts
@@ -4,6 +4,7 @@ import {
getCachedUserInfo,
getServiceWorkerRegistration,
} from '@capital/common';
+import { hasSilent } from './silent';
export function initNotify() {
if (Notification.permission === 'default') {
@@ -13,7 +14,14 @@ export function initNotify() {
regSocketEventListener({
eventName: 'chat.message.add',
eventFn: (message) => {
+ const converseId = message.converseId;
const currentUserId = getGlobalState()?.user.info._id;
+
+ if (hasSilent(converseId)) {
+ // 免打扰
+ return;
+ }
+
if (currentUserId !== message.author) {
// 创建通知
diff --git a/client/web/plugins/com.msgbyte.notify/src/silent.ts b/client/web/plugins/com.msgbyte.notify/src/silent.ts
new file mode 100644
index 00000000..bf29faee
--- /dev/null
+++ b/client/web/plugins/com.msgbyte.notify/src/silent.ts
@@ -0,0 +1,39 @@
+/**
+ * 免打扰
+ */
+
+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 990643cb..58074a46 100644
--- a/client/web/src/components/GroupPanelItem.tsx
+++ b/client/web/src/components/GroupPanelItem.tsx
@@ -1,4 +1,4 @@
-import { Badge, Typography } from 'antd';
+import { Badge, Space, Typography } from 'antd';
import clsx from 'clsx';
import React from 'react';
import { useLocation } from 'react-router';
@@ -13,6 +13,7 @@ export const GroupPanelItem: React.FC<{
icon: React.ReactNode;
to: string;
badge?: boolean;
+ extraBadge?: React.ReactNode[];
}> = React.memo((props) => {
const { icon, name, to, badge } = props;
const location = useLocation();
@@ -37,11 +38,15 @@ export const GroupPanelItem: React.FC<{
{name}
- {badge === true ? (
-
- ) : (
-
- )}
+
+ {badge === true ? (
+
+ ) : (
+
+ )}
+
+ {props.extraBadge}
+
);
diff --git a/client/web/src/plugin/common/reg.ts b/client/web/src/plugin/common/reg.ts
index d6240148..98b54478 100644
--- a/client/web/src/plugin/common/reg.ts
+++ b/client/web/src/plugin/common/reg.ts
@@ -46,6 +46,13 @@ export interface PluginCustomPanel {
export const [pluginCustomPanel, regCustomPanel] =
buildRegList();
+export interface PluginPanelMenu {
+ name: string;
+ label: string;
+ icon?: string;
+ onClick: (panelInfo: GroupPanel) => void;
+}
+
/**
* 注册群组面板
*/
@@ -79,12 +86,7 @@ export interface PluginGroupPanel {
/**
* 面板项右键菜单
*/
- menus?: {
- name: string;
- label: string;
- icon?: string;
- onClick: (panelInfo: GroupPanel) => void;
- }[];
+ menus?: PluginPanelMenu[];
}
export const [pluginGroupPanel, regGroupPanel] =
buildRegList();
@@ -212,3 +214,19 @@ export const [pluginPanelActions, regPluginPanelAction] = buildRegList<
*/
export const [pluginPermission, regPluginPermission] =
buildRegList();
+
+/**
+ * 注册自定义群组面板badge
+ */
+export const [pluginGroupPanelBadges, regGroupPanelBadge] = buildRegList<{
+ name: string;
+ render: (groupId: string, panelId: string) => React.ReactNode;
+}>();
+
+/**
+ * 注册自定义群组文本面板项额外操作菜单
+ */
+export const [
+ pluginGroupTextPanelExtraMenus,
+ regPluginGroupTextPanelExtraMenu,
+] = buildRegList();
diff --git a/client/web/src/routes/Main/Content/Group/SidebarItem.tsx b/client/web/src/routes/Main/Content/Group/SidebarItem.tsx
index c996a6d4..b12ad136 100644
--- a/client/web/src/routes/Main/Content/Group/SidebarItem.tsx
+++ b/client/web/src/routes/Main/Content/Group/SidebarItem.tsx
@@ -1,4 +1,4 @@
-import React, { useMemo } from 'react';
+import React from 'react';
import {
groupActions,
GroupPanel,
@@ -18,41 +18,7 @@ import { usePanelWindow } from '@/hooks/usePanelWindow';
import { LoadingSpinner } from '@/components/LoadingSpinner';
import _compact from 'lodash/compact';
import { Icon } from 'tailchat-design';
-import { findPluginPanelInfoByName } from '@/utils/plugin-helper';
-import type { ItemType } from 'antd/lib/menu/hooks/useItems';
-
-/**
- * 获取来自插件的菜单项
- */
-function useExtraMenuItems(panel: GroupPanel): ItemType[] {
- const pluginPanelInfo = useMemo(
- () =>
- panel.pluginPanelName && findPluginPanelInfoByName(panel.pluginPanelName),
- [panel.pluginPanelName]
- );
- if (!pluginPanelInfo) {
- return [];
- }
-
- if (
- Array.isArray(pluginPanelInfo.menus) &&
- pluginPanelInfo.menus.length > 0
- ) {
- return [
- {
- type: 'divider',
- },
- ...pluginPanelInfo.menus.map((item) => ({
- key: item.name,
- label: item.label,
- icon: item.icon ? : undefined,
- onClick: () => item.onClick(panel),
- })),
- ];
- }
-
- return [];
-}
+import { useExtraMenuItems, useGroupPanelExtraBadge } from './utils';
/**
* 群组面板侧边栏组件
@@ -62,20 +28,22 @@ export const SidebarItem: React.FC<{
panel: GroupPanel;
}> = React.memo((props) => {
const { groupId, panel } = props;
+ const panelId = panel.id;
const { hasOpenedPanel, openPanelWindow } = usePanelWindow(
- `/panel/group/${groupId}/${panel.id}`
+ `/panel/group/${groupId}/${panelId}`
);
const groupInfo = useGroupInfo(groupId);
const dispatch = useAppDispatch();
- const { markConverseAllAck } = useConverseAck(panel.id);
+ const { markConverseAllAck } = useConverseAck(panelId);
const extraMenuItems = useExtraMenuItems(panel);
+ const extraBadge = useGroupPanelExtraBadge(groupId, panelId);
if (!groupInfo) {
return ;
}
const isPinned =
- isValidStr(groupInfo.pinnedPanelId) && groupInfo.pinnedPanelId === panel.id;
+ isValidStr(groupInfo.pinnedPanelId) && groupInfo.pinnedPanelId === panelId;
const menu = (
,
onClick: () => {
- copy(`${location.origin}/main/group/${groupId}/${panel.id}`);
+ copy(`${location.origin}/main/group/${groupId}/${panelId}`);
showToasts(t('已复制到剪切板'));
},
},
@@ -117,7 +85,7 @@ export const SidebarItem: React.FC<{
dispatch(
groupActions.pinGroupPanel({
groupId,
- panelId: panel.id,
+ panelId: panelId,
})
);
},
@@ -144,7 +112,8 @@ export const SidebarItem: React.FC<{
)}
diff --git a/client/web/src/routes/Main/Content/Group/TextPanelItem.tsx b/client/web/src/routes/Main/Content/Group/TextPanelItem.tsx
index ba62efb4..5c82fd6b 100644
--- a/client/web/src/routes/Main/Content/Group/TextPanelItem.tsx
+++ b/client/web/src/routes/Main/Content/Group/TextPanelItem.tsx
@@ -1,6 +1,7 @@
import { GroupPanelItem } from '@/components/GroupPanelItem';
import React from 'react';
import { GroupPanel, useGroupTextPanelUnread } from 'tailchat-shared';
+import { useGroupPanelExtraBadge } from './utils';
interface GroupTextPanelItemProps {
groupId: string;
@@ -16,6 +17,7 @@ export const GroupTextPanelItem: React.FC = React.memo(
const { groupId, panel } = props;
const panelId = panel.id;
const hasUnread = useGroupTextPanelUnread(panelId);
+ const extraBadge = useGroupPanelExtraBadge(groupId, panelId);
return (
= React.memo(
icon={props.icon}
to={`/main/group/${groupId}/${panel.id}`}
badge={hasUnread}
+ extraBadge={extraBadge}
/>
);
}
diff --git a/client/web/src/routes/Main/Content/Group/utils.ts b/client/web/src/routes/Main/Content/Group/utils.ts
deleted file mode 100644
index 7ba58f66..00000000
--- a/client/web/src/routes/Main/Content/Group/utils.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { useParams } from 'react-router';
-
-/**
- * 获取群组面板的参数
- */
-export function useGroupPanelParams(): {
- groupId: string;
- panelId: string;
-} {
- const { groupId = '', panelId = '' } = useParams<{
- groupId: string;
- panelId: string;
- }>();
-
- return { groupId, panelId };
-}
diff --git a/client/web/src/routes/Main/Content/Group/utils.tsx b/client/web/src/routes/Main/Content/Group/utils.tsx
new file mode 100644
index 00000000..7147c506
--- /dev/null
+++ b/client/web/src/routes/Main/Content/Group/utils.tsx
@@ -0,0 +1,84 @@
+import {
+ pluginGroupPanelBadges,
+ pluginGroupTextPanelExtraMenus,
+} from '@/plugin/common';
+import { findPluginPanelInfoByName } from '@/utils/plugin-helper';
+import type { ItemType } from 'antd/lib/menu/hooks/useItems';
+import React, { useMemo } from 'react';
+import { useParams } from 'react-router';
+import { Icon } from 'tailchat-design';
+import {
+ GroupPanel,
+ GroupPanelType,
+ isValidStr,
+ useSharedEventHandler,
+} from 'tailchat-shared';
+import { useUpdate } from 'ahooks';
+
+/**
+ * 获取群组面板的参数
+ */
+export function useGroupPanelParams(): {
+ groupId: string;
+ panelId: string;
+} {
+ const { groupId = '', panelId = '' } = useParams<{
+ groupId: string;
+ panelId: string;
+ }>();
+
+ return { groupId, panelId };
+}
+
+/**
+ * 获取来自插件的菜单项
+ */
+export function useExtraMenuItems(panel: GroupPanel): ItemType[] {
+ const extraMenuItems = useMemo(() => {
+ if (panel.type === GroupPanelType.TEXT) {
+ return pluginGroupTextPanelExtraMenus;
+ } else {
+ return isValidStr(panel.pluginPanelName)
+ ? findPluginPanelInfoByName(panel.pluginPanelName)?.menus ?? []
+ : [];
+ }
+ }, [panel.type, panel.pluginPanelName]);
+
+ if (Array.isArray(extraMenuItems) && extraMenuItems.length > 0) {
+ return [
+ {
+ type: 'divider',
+ },
+ ...extraMenuItems.map((item) => ({
+ key: item.name,
+ label: item.label,
+ icon: item.icon ? : undefined,
+ onClick: () => item.onClick(panel),
+ })),
+ ];
+ }
+
+ return [];
+}
+
+/**
+ * 获取群组面板额外badge
+ */
+export function useGroupPanelExtraBadge(
+ groupId: string,
+ panelId: string
+): React.ReactNode[] {
+ const update = useUpdate();
+
+ useSharedEventHandler('groupPanelBadgeUpdate', () => {
+ update();
+ });
+
+ const extraBadge = pluginGroupPanelBadges.map((item) => (
+
+ {item.render(groupId, panelId)}
+
+ ));
+
+ return extraBadge;
+}
diff --git a/client/web/src/styles/antd/dark.less b/client/web/src/styles/antd/dark.less
index f5df598b..0322b167 100644
--- a/client/web/src/styles/antd/dark.less
+++ b/client/web/src/styles/antd/dark.less
@@ -88,6 +88,9 @@
border-top-color: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.85);
}
+ .ant-dropdown-menu-item-divider, .ant-dropdown-menu-submenu-title-divider {
+ background-color: rgba(255, 255, 255, 0.12);
+ }
// 排版
.ant-typography {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 22e7f464..ae999817 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -292,6 +292,7 @@ importers:
'@types/webpack': ^5.28.0
'@types/webpack-bundle-analyzer': ^4.4.1
'@types/webpack-dev-server': ^4.3.1
+ ahooks: ^3.7.1
antd: ^4.19.5
autoprefixer: ^10.2.6
axios: ^0.21.1
@@ -371,6 +372,7 @@ importers:
yup: ^0.32.9
dependencies:
'@loadable/component': 5.15.2_react@18.2.0
+ ahooks: 3.7.1_react@18.2.0
antd: 4.22.8_biqbaboplfbrettd7655fr4n2y
axios: 0.21.4
clsx: 1.2.1