feat(plugin-notification-in-app) (#5254)

feat: Add inapp live message notifications.
---------

Co-authored-by: chenos <chenlinxh@gmail.com>
Co-authored-by: mytharcher <mytharcher@gmail.com>
This commit is contained in:
Sheldon Guo 2024-10-25 22:41:30 +08:00 committed by GitHub
parent 158ef760fc
commit 056728d7ab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
71 changed files with 2996 additions and 149 deletions

View File

@ -70,7 +70,6 @@ export class APIClient extends APIClientSDK {
api.auth = this.auth;
api.storagePrefix = this.storagePrefix;
api.notification = this.notification;
api.axios = this.axios;
return api;
}

View File

@ -27,7 +27,7 @@ export const PinnedPluginListProvider: React.FC<{ items: any }> = (props) => {
export const PinnedPluginList = () => {
const { allowAll, snippets } = useACLRoleContext();
const getSnippetsAllow = (aclKey) => {
return allowAll || snippets?.includes(aclKey);
return allowAll || aclKey === '*' || snippets?.includes(aclKey);
};
const ctx = useContext(PinnedPluginListContext);
const { components } = useContext(SchemaOptionsContext);

View File

@ -10,6 +10,7 @@
import merge from 'deepmerge';
import { EventEmitter } from 'events';
import { default as _, default as lodash } from 'lodash';
import safeJsonStringify from 'safe-json-stringify';
import {
ModelOptions,
ModelStatic,
@ -25,7 +26,6 @@ import { BelongsToField, Field, FieldOptions, HasManyField } from './fields';
import { Model } from './model';
import { Repository } from './repository';
import { checkIdentifier, md5, snakeCase } from './utils';
import safeJsonStringify from 'safe-json-stringify';
export type RepositoryType = typeof Repository;
@ -864,6 +864,16 @@ export class Collection<
return `${schema}.${tableName}`;
}
public getRealTableName(quoted = false) {
const realname = this.tableNameAsString();
return !quoted ? realname : this.db.sequelize.getQueryInterface().quoteIdentifiers(realname);
}
public getRealFieldName(name: string, quoted = false) {
const realname = this.model.getAttributes()[name].field;
return !quoted ? name : this.db.sequelize.getQueryInterface().quoteIdentifier(realname);
}
public getTableNameWithSchemaAsString() {
const tableName = this.model.tableName;

View File

@ -26,7 +26,7 @@ import { i18n } from './middlewares/i18n';
export function createI18n(options: ApplicationOptions) {
const instance = i18next.createInstance();
instance.init({
lng: 'en-US',
lng: process.env.INIT_LANG || 'en-US',
resources: {},
keySeparator: false,
nsSeparator: false,

View File

@ -0,0 +1,95 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { SchemaComponent, css } from '@nocobase/client';
import { useNotifyMailTranslation } from './hooks/useTranslation';
export const ContentConfigForm = ({ variableOptions }) => {
const { t } = useNotifyMailTranslation();
return (
<SchemaComponent
scope={{ t }}
schema={{
type: 'void',
properties: {
subject: {
type: 'string',
required: true,
title: `{{t("Subject")}}`,
'x-decorator': 'FormItem',
'x-component': 'Variable.TextArea',
'x-component-props': {
scope: variableOptions,
},
},
contentType: {
type: 'string',
title: `{{t("Content type")}}`,
'x-decorator': 'FormItem',
'x-component': 'Radio.Group',
enum: [
{ label: 'HTML', value: 'html' },
{ label: `{{t("Plain text")}}`, value: 'text' },
],
default: 'html',
},
html: {
type: 'string',
required: true,
title: `{{t("Content")}}`,
'x-decorator': 'FormItem',
'x-component': 'Variable.RawTextArea',
'x-component-props': {
scope: variableOptions,
placeholder: 'Hi,',
autoSize: {
minRows: 10,
},
},
'x-reactions': [
{
dependencies: ['contentType'],
fulfill: {
state: {
visible: '{{$deps[0] === "html"}}',
},
},
},
],
},
text: {
type: 'string',
required: true,
title: `{{t("Content")}}`,
'x-decorator': 'FormItem',
'x-component': 'Variable.RawTextArea',
'x-component-props': {
scope: variableOptions,
placeholder: 'Hi,',
autoSize: {
minRows: 10,
},
},
'x-reactions': [
{
dependencies: ['contentType'],
fulfill: {
state: {
visible: '{{$deps[0] === "text"}}',
},
},
},
],
},
},
}}
/>
);
};

View File

@ -13,6 +13,7 @@ import { tval } from '@nocobase/utils/client';
import { channelType, NAMESPACE } from '../constant';
import { ChannelConfigForm } from './ConfigForm';
import { MessageConfigForm } from './MessageConfigForm';
import { ContentConfigForm } from './ContentConfigForm';
export class PluginNotificationMailClient extends Plugin {
async afterAdd() {}
@ -25,6 +26,7 @@ export class PluginNotificationMailClient extends Plugin {
components: {
ChannelConfigForm: ChannelConfigForm,
MessageConfigForm: MessageConfigForm,
ContentConfigForm,
},
});
}

View File

@ -29,8 +29,10 @@ type Message = {
export class MailNotificationChannel extends BaseNotificationChannel {
transpoter: Transporter;
async send(args): Promise<any> {
const { message, channel } = args;
const { message, channel, receivers } = args;
const { host, port, secure, account, password, from } = channel.options;
const userRepo = this.app.db.getRepository('users');
try {
const transpoter: Transporter = nodemailer.createTransport({
host,
@ -42,27 +44,43 @@ export class MailNotificationChannel extends BaseNotificationChannel {
},
});
const { subject, cc, bcc, to, contentType } = message;
const payload = {
to: to.map((item) => item?.trim()).filter(Boolean),
cc: cc
? cc
.flat()
.map((item) => item?.trim())
.filter(Boolean)
: undefined,
bcc: bcc
? bcc
.flat()
.map((item) => item?.trim())
.filter(Boolean)
: undefined,
subject,
from,
...(contentType === 'html' ? { html: message.html } : { text: message.text }),
};
if (receivers?.type === 'userId') {
const users = await userRepo.find({
filter: {
$in: receivers.value,
},
});
const usersEmail = users.map((user) => user.email).filter(Boolean);
const payload = {
to: usersEmail,
from,
...(contentType === 'html' ? { html: message.html } : { text: message.text }),
};
const result = await transpoter.sendMail(payload);
return { status: 'success', message };
} else {
const payload = {
to: to.map((item) => item?.trim()).filter(Boolean),
cc: cc
? cc
.flat()
.map((item) => item?.trim())
.filter(Boolean)
: undefined,
bcc: bcc
? bcc
.flat()
.map((item) => item?.trim())
.filter(Boolean)
: undefined,
subject,
from,
...(contentType === 'html' ? { html: message.html } : { text: message.text }),
};
const result = await transpoter.sendMail(payload);
return { status: 'success', message };
const result = await transpoter.sendMail(payload);
return { status: 'success', message };
}
} catch (error) {
throw { status: 'failure', reason: error.message, message };
}

View File

@ -0,0 +1,2 @@
/node_modules
/src

View File

@ -0,0 +1 @@
# @nocobase/plugin-notification-in-app-message

View File

@ -0,0 +1,2 @@
export * from './dist/client';
export { default } from './dist/client';

View File

@ -0,0 +1 @@
module.exports = require('./dist/client/index.js');

View File

@ -0,0 +1,25 @@
{
"name": "@nocobase/plugin-notification-in-app-message",
"version": "1.4.0-alpha",
"displayName": "Notification: In-app message",
"displayName.zh-CN": "通知:站内信",
"description": "It supports users in receiving real-time message notifications within the NocoBase application.",
"description.zh-CN": "支持用户在 NocoBase 应用内实时接收消息通知。",
"keywords": [
"Notification"
],
"main": "dist/server/index.js",
"dependencies": {
"immer": "^10.1.1"
},
"peerDependencies": {
"@formily/reactive": "^2",
"@formily/reactive-react": "^2",
"@nocobase/client": "1.x",
"@nocobase/plugin-notification-manager": "1.x",
"@nocobase/server": "1.x",
"@nocobase/test": "1.x",
"react-router-dom": "^6.x"
}
}

View File

@ -0,0 +1,2 @@
export * from './dist/server';
export { default } from './dist/server';

View File

@ -0,0 +1 @@
module.exports = require('./dist/server/index.js');

View File

@ -0,0 +1,22 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { Icon, PinnedPluginListProvider, SchemaComponentOptions, useApp, useRequest } from '@nocobase/client';
import { Inbox } from './components/Inbox';
export const MessageManagerProvider = (props: any) => {
return (
<PinnedPluginListProvider
items={{
inbox: { order: 301, component: 'Inbox', pin: true, snippet: '*' },
}}
>
<SchemaComponentOptions components={{ Inbox }}>{props.children}</SchemaComponentOptions>
</PinnedPluginListProvider>
);
};

View File

@ -0,0 +1,249 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
// CSS modules
type CSSModuleClasses = { readonly [key: string]: string };
declare module '*.module.css' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.scss' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.sass' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.less' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.styl' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.stylus' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.pcss' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.sss' {
const classes: CSSModuleClasses;
export default classes;
}
// CSS
declare module '*.css' { }
declare module '*.scss' { }
declare module '*.sass' { }
declare module '*.less' { }
declare module '*.styl' { }
declare module '*.stylus' { }
declare module '*.pcss' { }
declare module '*.sss' { }
// Built-in asset types
// see `src/node/constants.ts`
// images
declare module '*.apng' {
const src: string;
export default src;
}
declare module '*.png' {
const src: string;
export default src;
}
declare module '*.jpg' {
const src: string;
export default src;
}
declare module '*.jpeg' {
const src: string;
export default src;
}
declare module '*.jfif' {
const src: string;
export default src;
}
declare module '*.pjpeg' {
const src: string;
export default src;
}
declare module '*.pjp' {
const src: string;
export default src;
}
declare module '*.gif' {
const src: string;
export default src;
}
declare module '*.svg' {
const src: string;
export default src;
}
declare module '*.ico' {
const src: string;
export default src;
}
declare module '*.webp' {
const src: string;
export default src;
}
declare module '*.avif' {
const src: string;
export default src;
}
// media
declare module '*.mp4' {
const src: string;
export default src;
}
declare module '*.webm' {
const src: string;
export default src;
}
declare module '*.ogg' {
const src: string;
export default src;
}
declare module '*.mp3' {
const src: string;
export default src;
}
declare module '*.wav' {
const src: string;
export default src;
}
declare module '*.flac' {
const src: string;
export default src;
}
declare module '*.aac' {
const src: string;
export default src;
}
declare module '*.opus' {
const src: string;
export default src;
}
declare module '*.mov' {
const src: string;
export default src;
}
declare module '*.m4a' {
const src: string;
export default src;
}
declare module '*.vtt' {
const src: string;
export default src;
}
// fonts
declare module '*.woff' {
const src: string;
export default src;
}
declare module '*.woff2' {
const src: string;
export default src;
}
declare module '*.eot' {
const src: string;
export default src;
}
declare module '*.ttf' {
const src: string;
export default src;
}
declare module '*.otf' {
const src: string;
export default src;
}
// other
declare module '*.webmanifest' {
const src: string;
export default src;
}
declare module '*.pdf' {
const src: string;
export default src;
}
declare module '*.txt' {
const src: string;
export default src;
}
// wasm?init
declare module '*.wasm?init' {
const initWasm: (options?: WebAssembly.Imports) => Promise<WebAssembly.Instance>;
export default initWasm;
}
// web worker
declare module '*?worker' {
const workerConstructor: {
new(options?: { name?: string }): Worker;
};
export default workerConstructor;
}
declare module '*?worker&inline' {
const workerConstructor: {
new(options?: { name?: string }): Worker;
};
export default workerConstructor;
}
declare module '*?worker&url' {
const src: string;
export default src;
}
declare module '*?sharedworker' {
const sharedWorkerConstructor: {
new(options?: { name?: string }): SharedWorker;
};
export default sharedWorkerConstructor;
}
declare module '*?sharedworker&inline' {
const sharedWorkerConstructor: {
new(options?: { name?: string }): SharedWorker;
};
export default sharedWorkerConstructor;
}
declare module '*?sharedworker&url' {
const src: string;
export default src;
}
declare module '*?raw' {
const src: string;
export default src;
}
declare module '*?url' {
const src: string;
export default src;
}
declare module '*?inline' {
const src: string;
export default src;
}

View File

@ -0,0 +1,71 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { SchemaComponent, css } from '@nocobase/client';
import { useLocalTranslation } from '../../locale';
import { tval } from '@nocobase/utils/client';
export const ContentConfigForm = ({ variableOptions }) => {
const { t } = useLocalTranslation();
return (
<SchemaComponent
scope={{ t }}
schema={{
type: 'void',
properties: {
title: {
type: 'string',
required: true,
title: `{{t("Message title")}}`,
'x-decorator': 'FormItem',
'x-component': 'Variable.TextArea',
'x-component-props': {
scope: variableOptions,
useTypedConstant: ['string'],
},
},
content: {
type: 'string',
required: true,
title: `{{t("Message content")}}`,
'x-decorator': 'FormItem',
'x-component': 'Variable.RawTextArea',
'x-component-props': {
scope: variableOptions,
placeholder: 'Hi,',
autoSize: {
minRows: 10,
},
},
},
options: {
type: 'object',
properties: {
url: {
type: 'string',
required: false,
title: `{{t("Detail URL")}}`,
'x-decorator': 'FormItem',
'x-component': 'Variable.TextArea',
'x-component-props': {
scope: variableOptions,
useTypedConstant: ['string'],
},
description: tval(
"Support two types of links in nocobase: internal links and external links. If using an internal link, the link starts with '/', for example, '/admin/page'. If using an external link, the link starts with 'http', for example, 'https://example.com'.",
),
},
},
},
},
}}
/>
);
};

View File

@ -0,0 +1,96 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please rwefer to: https://www.nocobase.com/agreement.
*/
import React, { useEffect, useCallback, useContext } from 'react';
import { Badge, Button, ConfigProvider, Drawer, Tooltip } from 'antd';
import { CloseOutlined } from '@ant-design/icons';
import { createStyles } from 'antd-style';
import { Icon } from '@nocobase/client';
import { InboxContent } from './InboxContent';
import { useLocalTranslation } from '../../locale';
import { fetchChannels } from '../observables';
import { observer } from '@formily/reactive-react';
import { useCurrentUserContext } from '@nocobase/client';
import {
updateUnreadMsgsCount,
unreadMsgsCountObs,
startMsgSSEStreamWithRetry,
inboxVisible,
userIdObs,
} from '../observables';
const useStyles = createStyles(({ token }) => {
return {
button: {
// @ts-ignore
color: token.colorTextHeaderMenu + ' !important',
},
};
});
const InnerInbox = (props) => {
const { t } = useLocalTranslation();
const { styles } = useStyles();
const ctx = useCurrentUserContext();
const currUserId = ctx.data?.data?.id;
useEffect(() => {
updateUnreadMsgsCount();
}, []);
useEffect(() => {
userIdObs.value = currUserId ?? null;
}, [currUserId]);
const onIconClick = useCallback(() => {
inboxVisible.value = true;
fetchChannels({});
}, []);
useEffect(() => {
startMsgSSEStreamWithRetry();
}, []);
const DrawerTitle = <div style={{ padding: '0' }}>{t('Message')}</div>;
const CloseIcon = (
<div style={{ marginLeft: '15px' }}>
<CloseOutlined />
</div>
);
return (
<ConfigProvider
theme={{
components: { Drawer: { paddingLG: 0 } },
}}
>
<Tooltip title={t('Message')}>
<Button className={styles.button} title={'Apps'} icon={<Icon type={'MailOutlined'} />} onClick={onIconClick} />
</Tooltip>
{unreadMsgsCountObs.value && <Badge count={unreadMsgsCountObs.value} size="small" offset={[-18, -16]}></Badge>}
<Drawer
title={DrawerTitle}
open={inboxVisible.value}
closeIcon={CloseIcon}
width={900}
onClose={() => {
inboxVisible.value = false;
}}
>
<InboxContent />
</Drawer>
</ConfigProvider>
);
};
export const Inbox = observer(InnerInbox);

View File

@ -0,0 +1,190 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { observer } from '@formily/reactive-react';
import { Layout, List, Badge, Button, Flex, Tabs, ConfigProvider, theme } from 'antd';
import { css } from '@emotion/css';
import { dayjs } from '@nocobase/utils/client';
import { useLocalTranslation } from '../../locale';
import {
fetchChannels,
selectedChannelNameObs,
channelListObs,
isFetchingChannelsObs,
showChannelLoadingMoreObs,
selectedMessageListObs,
channelStatusFilterObs,
ChannelStatus,
} from '../observables';
import { MessageList } from './MessageList';
const InnerInboxContent = () => {
const { token } = theme.useToken();
const { t } = useLocalTranslation();
const channels = channelListObs.value;
const messages = selectedMessageListObs.value;
const selectedChannelName = selectedChannelNameObs.value;
const onLoadChannelsMore = () => {
const filter: Record<string, any> = {};
const lastChannel = channels[channels.length - 1];
if (lastChannel?.latestMsgReceiveTimestamp) {
filter.latestMsgReceiveTimestamp = {
$lt: lastChannel.latestMsgReceiveTimestamp,
};
}
fetchChannels({ filter, limit: 30 });
};
const loadChannelsMore = showChannelLoadingMoreObs.value ? (
<div
style={{
textAlign: 'center',
marginTop: 12,
height: 32,
lineHeight: '32px',
}}
>
<Button loading={isFetchingChannelsObs.value} onClick={onLoadChannelsMore}>
{t('Loading more')}
</Button>
</div>
) : null;
const FilterTab = () => {
interface TabItem {
label: string;
key: ChannelStatus;
}
const items: Array<TabItem> = [
{ label: t('All'), key: 'all' },
{ label: t('Unread'), key: 'unread' },
{ label: t('Read'), key: 'read' },
];
return (
<ConfigProvider
theme={{
components: { Tabs: { horizontalItemMargin: '20px' } },
}}
>
<Tabs
activeKey={channelStatusFilterObs.value}
items={items}
onChange={(key: ChannelStatus) => {
channelStatusFilterObs.value = key;
fetchChannels({});
}}
/>
</ConfigProvider>
);
};
return (
<Layout style={{ height: '100%' }}>
<Layout.Sider
width={350}
style={{
height: '100%',
overflowY: 'auto',
background: token.colorBgContainer,
padding: '0 15px',
border: 'none',
}}
>
<FilterTab />
<List
itemLayout="horizontal"
dataSource={channels}
loadMore={loadChannelsMore}
style={{ paddingBottom: '20px' }}
loading={channels.length === 0 && isFetchingChannelsObs.value}
renderItem={(item) => {
const titleColor = selectedChannelName === item.name ? token.colorPrimaryText : token.colorText;
const textColor = selectedChannelName === item.name ? token.colorPrimaryText : token.colorTextTertiary;
return (
<List.Item
className={css`
&:hover {
background-color: ${token.colorBgTextHover}};
}
`}
style={{
padding: '10px 10px',
color: titleColor,
...(selectedChannelName === item.name ? { backgroundColor: token.colorPrimaryBg } : {}),
cursor: 'pointer',
marginTop: '10px',
border: 'none',
borderRadius: '10px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
}}
onClick={() => {
selectedChannelNameObs.value = item.name;
}}
>
<Flex justify="space-between" style={{ width: '100%' }}>
<div
style={{
width: '150px',
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap',
fontWeight: 'bold',
}}
>
{item.title}
</div>
<div
style={{
width: '120px',
fontWeight: 400,
textAlign: 'right',
fontFamily: 'monospace',
color: textColor,
}}
>
{dayjs(item.latestMsgReceiveTimestamp).fromNow()}
</div>
</Flex>
<Flex justify="space-between" style={{ width: '100%', marginTop: token.margin }}>
<div
style={{
width: '80%',
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap',
color: textColor,
}}
>
{' '}
{item.latestMsgTitle}
</div>
{channelStatusFilterObs.value !== 'read' ? (
<Badge style={{ border: 'none' }} count={item.unreadMsgCnt}></Badge>
) : null}
</Flex>
</List.Item>
);
}}
/>
</Layout.Sider>
<Layout.Content style={{ padding: token.paddingLG, height: '100%', overflowY: 'auto' }}>
{selectedChannelName ? <MessageList /> : null}
</Layout.Content>
</Layout>
);
};
export const InboxContent = observer(InnerInboxContent);

View File

@ -0,0 +1,120 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { SchemaComponent, css } from '@nocobase/client';
import { useLocalTranslation } from '../../locale';
import { UsersSelect } from './UsersSelect';
import { UsersAddition } from './UsersAddition';
import { tval } from '@nocobase/utils/client';
export const MessageConfigForm = ({ variableOptions }) => {
const { t } = useLocalTranslation();
return (
<SchemaComponent
scope={{ t }}
components={{ UsersSelect, UsersAddition }}
schema={{
type: 'void',
properties: {
receivers: {
type: 'array',
title: `{{t("Receivers")}}`,
'x-decorator': 'FormItem',
'x-component': 'ArrayItems',
items: {
type: 'void',
'x-component': 'Space',
'x-component-props': {
className: css`
width: 100%;
&.ant-space.ant-space-horizontal {
flex-wrap: nowrap;
}
> .ant-space-item:nth-child(2) {
flex-grow: 1;
}
`,
},
properties: {
sort: {
type: 'void',
'x-decorator': 'FormItem',
'x-component': 'ArrayItems.SortHandle',
},
input: {
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'UsersSelect',
},
remove: {
type: 'void',
'x-decorator': 'FormItem',
'x-component': 'ArrayItems.Remove',
},
},
},
required: true,
properties: {
add: {
type: 'void',
title: `{{t("Add receiver")}}`,
'x-component': 'UsersAddition',
},
},
},
title: {
type: 'string',
required: true,
title: `{{t("Message title")}}`,
'x-decorator': 'FormItem',
'x-component': 'Variable.TextArea',
'x-component-props': {
scope: variableOptions,
useTypedConstant: ['string'],
},
},
content: {
type: 'string',
required: true,
title: `{{t("Message content")}}`,
'x-decorator': 'FormItem',
'x-component': 'Variable.RawTextArea',
'x-component-props': {
scope: variableOptions,
placeholder: 'Hi,',
autoSize: {
minRows: 10,
},
},
},
options: {
type: 'object',
properties: {
url: {
type: 'string',
required: false,
title: `{{t("Detail URL")}}`,
'x-decorator': 'FormItem',
'x-component': 'Variable.TextArea',
'x-component-props': {
scope: variableOptions,
useTypedConstant: ['string'],
},
description: tval(
"Support two types of links in nocobase: internal links and external links. If using an internal link, the link starts with '/', for example, '/admin/page'. If using an external link, the link starts with 'http', for example, 'https://example.com'.",
),
},
},
},
},
}}
/>
);
};

View File

@ -0,0 +1,174 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React, { useState, useCallback } from 'react';
import { observer } from '@formily/reactive-react';
import { Card, Descriptions, Button, Spin, Tag, ConfigProvider, Typography, Tooltip, theme } from 'antd';
import { dayjs } from '@nocobase/utils/client';
import { useNavigate } from 'react-router-dom';
import { useLocalTranslation } from '../../locale';
import {
selectedChannelNameObs,
channelMapObs,
fetchMessages,
isFecthingMessageObs,
selectedMessageListObs,
showMsgLoadingMoreObs,
updateMessage,
inboxVisible,
} from '../observables';
export const MessageList = observer(() => {
const { t } = useLocalTranslation();
const navigate = useNavigate();
const { token } = theme.useToken();
const [hoveredMessageId, setHoveredMessageId] = useState<string | null>(null);
const selectedChannelName = selectedChannelNameObs.value;
const isFetchingMessages = isFecthingMessageObs.value;
const messages = selectedMessageListObs.value;
const msgStatusDict = {
read: t('Read'),
unread: t('Unread'),
};
if (!selectedChannelName) return null;
const onItemClicked = (message) => {
updateMessage({
filterByTk: message.id,
values: {
status: 'read',
},
});
if (message.options?.url) {
inboxVisible.value = false;
const url = message.options.url;
if (url.startsWith('/')) navigate(url);
else {
window.location.href = url;
}
}
};
const onLoadMessagesMore = useCallback(() => {
const filter: Record<string, any> = {};
const lastMessage = messages[messages.length - 1];
if (lastMessage) {
filter.receiveTimestamp = {
$lt: lastMessage.receiveTimestamp,
};
}
if (selectedChannelName) {
filter.channelName = selectedChannelName;
}
fetchMessages({ filter, limit: 30 });
}, [messages, selectedChannelName]);
return (
<ConfigProvider
theme={{
components: { Badge: { dotSize: 8 } },
}}
>
<Typography.Title level={4} style={{ marginBottom: token.marginLG }}>
{channelMapObs.value[selectedChannelName].title}
</Typography.Title>
{messages.length === 0 && isFecthingMessageObs.value ? (
<Spin style={{ width: '100%', marginTop: token.marginXXL }} />
) : (
messages.map((message, index) => (
<>
<Card
size={'small'}
bordered={false}
style={{ marginBottom: token.marginMD }}
onMouseEnter={() => {
setHoveredMessageId(message.id);
}}
onMouseLeave={() => {
setHoveredMessageId(null);
}}
title={
<Tooltip title={message.title} mouseEnterDelay={0.5}>
<div
onClick={() => {
onItemClicked(message);
}}
style={{
fontWeight: message.status === 'unread' ? 'bold' : 'normal',
cursor: 'pointer',
width: '100%',
}}
>
{message.title}
</div>
</Tooltip>
}
extra={
message.options?.url ? (
<Button
type="link"
onClick={(e) => {
e.stopPropagation();
onItemClicked(message);
}}
>
{t('View')}
</Button>
) : null
}
key={message.id}
>
<Descriptions key={index} column={1}>
<Descriptions.Item label={t('Content')}>
{' '}
<Tooltip title={message.content.length > 100 ? message.content : ''} mouseEnterDelay={0.5}>
{message.content.slice(0, 100) + (message.content.length > 100 ? '...' : '')}{' '}
</Tooltip>
</Descriptions.Item>
<Descriptions.Item label={t('Datetime')}>{dayjs(message.receiveTimestamp).fromNow()}</Descriptions.Item>
<Descriptions.Item label={t('Status')}>
<div style={{ height: token.controlHeight }}>
{hoveredMessageId === message.id && message.status === 'unread' ? (
<Button
type="link"
size="small"
style={{ fontSize: token.fontSizeSM }}
onClick={() => {
updateMessage({
filterByTk: message.id,
values: {
status: 'read',
},
});
}}
>
</Button>
) : (
<Tag color={message.status === 'unread' ? 'red' : 'green'}>{msgStatusDict[message.status]}</Tag>
)}
</div>
</Descriptions.Item>
</Descriptions>
</Card>
</>
))
)}
{showMsgLoadingMoreObs.value && (
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
<Button onClick={onLoadMessagesMore} loading={isFetchingMessages}>
{t('Loading more')}
</Button>
</div>
)}
</ConfigProvider>
);
});

View File

@ -0,0 +1,67 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { useField } from '@formily/react';
import { ArrayField as ArrayFieldModel } from '@formily/core';
import { Button, Popover, Radio, Space, Spin, Tag, Tooltip, Typography } from 'antd';
import { PlusOutlined, QuestionCircleOutlined } from '@ant-design/icons';
import React, { useCallback, useState } from 'react';
import { useWorkflowExecuted } from '@nocobase/plugin-workflow/client';
import { useLocalTranslation } from '../../locale';
export function UsersAddition() {
const disabled = useWorkflowExecuted();
/*
waiting for improvement
const array = ArrayItems.useArray();
*/
const [open, setOpen] = useState(false);
const { t } = useLocalTranslation();
const field = useField<ArrayFieldModel>();
/*
waiting for improvement
const array = ArrayItems.useArray();
*/
const { receivers } = field.form.values;
const onAddSelect = useCallback(() => {
receivers.push('');
setOpen(false);
}, [receivers]);
const onAddQuery = useCallback(() => {
receivers.push({ filter: {} });
setOpen(false);
}, [receivers]);
const button = (
<Button icon={<PlusOutlined />} type="dashed" block disabled={disabled} className="ant-formily-array-base-addition">
{t('Add user')}
</Button>
);
return disabled ? (
button
) : (
<Popover
open={open}
onOpenChange={setOpen}
content={
<Space direction="vertical" size="small">
<Button type="text" onClick={onAddSelect}>
{t('Select users')}
</Button>
<Button type="text" onClick={onAddQuery}>
{t('Query users')}
</Button>
</Space>
}
>
{button}
</Popover>
);
}

View File

@ -0,0 +1,88 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This program is offered under a commercial license.
* For more information, see <https://www.nocobase.com/agreement>
*/
import React from 'react';
import { RemoteSelect, SchemaComponent, Variable, useCollectionFilterOptions, useToken } from '@nocobase/client';
import { FilterDynamicComponent, useWorkflowVariableOptions } from '@nocobase/plugin-workflow/client';
import { useField } from '@formily/react';
function isUserKeyField(field) {
if (field.isForeignKey) {
return field.target === 'users';
}
return field.collectionName === 'users' && field.name === 'id';
}
export function UsersSelect(props) {
const valueType = typeof props.value;
return valueType === 'object' && props.value ? <UsersQuery {...props} /> : <InternalUsersSelect {...props} />;
}
function InternalUsersSelect({ value, onChange }) {
const scope = useWorkflowVariableOptions({ types: [isUserKeyField] });
return (
<Variable.Input scope={scope} value={value} onChange={onChange}>
<RemoteSelect
fieldNames={{
label: 'nickname',
value: 'id',
}}
service={{
resource: 'users',
}}
manual={false}
value={value}
onChange={onChange}
/>
</Variable.Input>
);
}
function UsersQuery(props) {
const field = useField<any>();
const options = useCollectionFilterOptions('users');
const { token } = useToken();
return (
<div
style={{
border: `1px dashed ${token.colorBorder}`,
padding: token.paddingSM,
}}
>
<SchemaComponent
basePath={field.address}
schema={{
type: 'void',
properties: {
filter: {
type: 'object',
'x-component': 'Filter',
'x-component-props': {
options,
dynamicComponent: FilterDynamicComponent,
},
},
},
}}
/>
</div>
);
}

View File

@ -0,0 +1,122 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useAPIClient, useRequest } from '@nocobase/client';
import { produce } from 'immer';
export type Message = {
id: string;
title: string;
receiveTimestamp: number;
content: string;
status: 'read' | 'unread';
};
export type Group = {
id: string;
title: string;
msgMap: Record<string, Message>;
unreadMsgCnt: number;
latestMsgReceiveTimestamp: number;
latestMsgTitle: string;
};
const useChats = () => {
const apiClient = useAPIClient();
const [groupMap, setGroupMap] = useState<Record<string, Group>>({});
const addChat = useCallback((chat) => {
setGroupMap(
produce((draft) => {
draft[chat.id] = chat;
}),
);
}, []);
const addChats = useCallback((groups) => {
setGroupMap(
produce((draft) => {
groups.forEach((group) => {
draft[group.id] = { ...draft[group.id], ...group };
if (!draft[group.id].msgMap) draft[group.id].msgMap = {};
});
}),
);
}, []);
const requestChats = useCallback(
async ({ filter = {}, limit = 30 }: { filter?: Record<string, any>; limit?: number }) => {
const res = await apiClient.request({
url: 'myInAppChannels:list',
method: 'get',
params: { filter, limit },
});
const chats = res.data.data.chats;
if (Array.isArray(chats)) return chats;
else return [];
},
[apiClient],
);
const addMessagesToGroup = useCallback(
async (groupId: string, messages: Message[]) => {
const groups = await requestChats({ filter: { id: groupId } });
if (groups.length < 1) return;
const group = groups[0];
if (group)
setGroupMap(
produce((draft) => {
draft[groupId] = { ...(draft[groupId] ?? {}), ...group };
if (!draft[groupId].msgMap) draft[groupId].msgMap = {};
messages.forEach((message) => {
draft[groupId].msgMap[message.id] = message;
});
}),
);
},
[requestChats],
);
const chatList = useMemo(() => {
return Object.values(groupMap).sort((a, b) => (a.latestMsgReceiveTimestamp > b.latestMsgReceiveTimestamp ? -1 : 1));
}, [groupMap]);
const fetchChats = useCallback(
async ({ filter = {}, limit = 30 }: { filter?: Record<string, any>; limit?: number }) => {
const res = await apiClient.request({
url: 'myInAppChannels:list',
method: 'get',
params: { filter, limit },
});
const chats = res.data.data.chats;
if (Array.isArray(chats)) addChats(chats);
},
[apiClient, addChats],
);
const fetchMessages = useCallback(
async ({ filter }) => {
const res = await apiClient.request({
url: 'myInAppMessages:list',
method: 'get',
params: {
filter,
},
});
addMessagesToGroup(filter.channelName, res.data.data.messages);
},
[apiClient, addMessagesToGroup],
);
return {
chatMap: groupMap,
chatList,
addChat,
addChats,
fetchChats,
fetchMessages,
addMessagesToGroup,
};
};
export default useChats;

View File

@ -0,0 +1,44 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Plugin } from '@nocobase/client';
import { MessageManagerProvider } from './MessageManagerProvider';
import NotificationManager from '@nocobase/plugin-notification-manager/client';
import { tval } from '@nocobase/utils/client';
import { MessageConfigForm } from './components/MessageConfigForm';
import { ContentConfigForm } from './components/ContentConfigForm';
import { NAMESPACE } from '../locale';
import { setAPIClient } from './utils';
export class PluginNotificationInAppClient extends Plugin {
async afterAdd() {}
async beforeLoad() {}
async load() {
setAPIClient(this.app.apiClient);
this.app.use(MessageManagerProvider);
const notification = this.pm.get(NotificationManager);
notification.registerChannelType({
title: tval('In-app message', { ns: NAMESPACE }),
type: 'in-app-message',
components: {
ChannelConfigForm: () => null,
MessageConfigForm: MessageConfigForm,
ContentConfigForm,
},
meta: {
editable: true,
creatable: true,
deletable: true,
},
});
}
}
export default PluginNotificationInAppClient;

View File

@ -0,0 +1,84 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { observable, autorun, reaction } from '@formily/reactive';
import { Channel } from '../../types';
import { getAPIClient } from '../utils';
import { merge } from '@nocobase/utils/client';
import { userIdObs } from './user';
export type ChannelStatus = 'all' | 'read' | 'unread';
export enum InappChannelStatusEnum {
all = 'all',
read = 'read',
unread = 'unread',
}
export const channelMapObs = observable<{ value: Record<string, Channel> }>({ value: {} });
export const isFetchingChannelsObs = observable<{ value: boolean }>({ value: false });
export const channelCountObs = observable<{ value: number }>({ value: 0 });
export const channelStatusFilterObs = observable<{ value: ChannelStatus }>({ value: 'all' });
export const channelListObs = observable.computed(() => {
const channels = Object.values(channelMapObs.value)
.filter((channel) => channel.userId == String(userIdObs.value ?? ''))
.filter((channel) => {
if (channelStatusFilterObs.value === 'read') return channel.totalMsgCnt - channel.unreadMsgCnt > 0;
else if (channelStatusFilterObs.value === 'unread') return channel.unreadMsgCnt > 0;
else return true;
})
.sort((a, b) => (a.latestMsgReceiveTimestamp > b.latestMsgReceiveTimestamp ? -1 : 1));
return channels;
}) as { value: Channel[] };
export const showChannelLoadingMoreObs = observable.computed(() => {
if (channelListObs.value.length < channelCountObs.value) return true;
else return false;
}) as { value: boolean };
export const selectedChannelNameObs = observable<{ value: string | null }>({ value: null });
export const fetchChannels = async (params: any) => {
const apiClient = getAPIClient();
isFetchingChannelsObs.value = true;
const res = await apiClient.request({
url: 'myInAppChannels:list',
method: 'get',
params: merge({ filter: { status: channelStatusFilterObs.value } }, params ?? {}),
});
const channels = res.data?.data;
if (Array.isArray(channels)) {
channels.forEach((channel: Channel) => {
channelMapObs.value[channel.name] = channel;
});
}
const count = res.data?.meta?.count;
if (count >= 0) channelCountObs.value = count;
isFetchingChannelsObs.value = false;
};
autorun(() => {
if (!selectedChannelNameObs.value && channelListObs.value[0]?.name) {
selectedChannelNameObs.value = channelListObs.value[0].name;
} else if (channelListObs.value.length === 0) {
selectedChannelNameObs.value = null;
} else if (
channelListObs.value.length > 0 &&
!channelListObs.value.find((channel) => channel.name === selectedChannelNameObs.value)
) {
selectedChannelNameObs.value = null;
}
});
reaction(
() => channelStatusFilterObs.value,
() => {
if (channelListObs.value[0]?.name) {
selectedChannelNameObs.value = channelListObs.value[0].name;
}
},
{ fireImmediately: true },
);

View File

@ -0,0 +1,12 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { observable } from '@formily/reactive';
export const inboxVisible = observable<{ value: boolean }>({ value: false });

View File

@ -0,0 +1,14 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
export * from './channel';
export * from './message';
export * from './sse';
export * from './inbox';
export * from './user';

View File

@ -0,0 +1,114 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { observable, autorun } from '@formily/reactive';
import { Message } from '../../types';
import { getAPIClient } from '../utils';
import {
channelMapObs,
selectedChannelNameObs,
fetchChannels,
InappChannelStatusEnum,
channelStatusFilterObs,
} from './channel';
import { userIdObs } from './user';
import { InAppMessagesDefinition } from '../../types';
import { merge } from '@nocobase/utils/client';
export const messageMapObs = observable<{ value: Record<string, Message> }>({ value: {} });
export const isFecthingMessageObs = observable<{ value: boolean }>({ value: false });
export const messageListObs = observable.computed(() => {
return Object.values(messageMapObs.value).sort((a, b) => (a.receiveTimestamp > b.receiveTimestamp ? -1 : 1));
}) as { value: Message[] };
const filterMessageByStatus = (message: Message) => {
if (channelStatusFilterObs.value === 'read') return message.status === 'read';
else if (channelStatusFilterObs.value === 'unread') return message.status === 'unread';
else return true;
};
const filterMessageByUserId = (message: Message) => {
return message.userId == String(userIdObs.value ?? '');
};
export const selectedMessageListObs = observable.computed(() => {
if (selectedChannelNameObs.value) {
const filteredMessages = messageListObs.value.filter(
(message) =>
message.channelName === selectedChannelNameObs.value && filterMessageByStatus(message) && filterMessageByUserId,
);
return filteredMessages;
} else {
return [];
}
}) as { value: Message[] };
export const fetchMessages = async (params: any = { limit: 30 }) => {
isFecthingMessageObs.value = true;
if (channelStatusFilterObs.value !== 'all')
params.filter = merge(params.filter ?? {}, { status: channelStatusFilterObs.value });
const apiClient = getAPIClient();
const res = await apiClient.request({
url: 'myInAppMessages:list',
method: 'get',
params,
});
const messages = res?.data?.data.messages;
if (Array.isArray(messages)) {
messages.forEach((message: Message) => {
messageMapObs.value[message.id] = message;
});
}
isFecthingMessageObs.value = false;
};
export const updateMessage = async (params: { filterByTk: any; values: Record<any, any> }) => {
const apiClient = getAPIClient();
await apiClient.request({
resource: InAppMessagesDefinition.name,
action: 'update',
method: 'post',
params,
});
const unupdatedMessage = messageMapObs.value[params.filterByTk];
messageMapObs.value[params.filterByTk] = { ...unupdatedMessage, ...params.values };
// fetchChannels({ filter: { name: unupdatedMessage.channelName, status: InappChannelStatusEnum.all } });
updateUnreadMsgsCount();
};
autorun(() => {
if (selectedChannelNameObs.value) {
fetchMessages({ filter: { channelName: selectedChannelNameObs.value } });
}
});
export const unreadMsgsCountObs = observable<{ value: number | null }>({ value: null });
export const updateUnreadMsgsCount = async () => {
const apiClient = getAPIClient();
const res = await apiClient.request({
url: 'myInAppMessages:count',
method: 'get',
params: { filter: { status: 'unread' } },
});
unreadMsgsCountObs.value = res?.data?.data.count;
};
export const showMsgLoadingMoreObs = observable.computed(() => {
const selectedChannelId = selectedChannelNameObs.value;
if (!selectedChannelId) return false;
const selectedChannel = channelMapObs.value[selectedChannelId];
const selectedMessageList = selectedMessageListObs.value;
const isMoreMessageByStatus = {
read: selectedChannel.totalMsgCnt - selectedChannel.unreadMsgCnt > selectedMessageList.length,
unread: selectedChannel.unreadMsgCnt > selectedMessageList.length,
all: selectedChannel.totalMsgCnt > selectedMessageList.length,
};
if (isMoreMessageByStatus[channelStatusFilterObs.value] && selectedMessageList.length > 0) {
return true;
}
}) as { value: boolean };

View File

@ -0,0 +1,102 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { observable, autorun, reaction } from '@formily/reactive';
import { notification } from 'antd';
import { SSEData } from '../../types';
import { messageMapObs, updateUnreadMsgsCount } from './message';
import { channelMapObs, fetchChannels, selectedChannelNameObs } from './channel';
import { inboxVisible } from './inbox';
import { getAPIClient } from '../utils';
import { uid } from '@nocobase/utils/client';
export const liveSSEObs = observable<{ value: SSEData | null }>({ value: null });
reaction(
() => liveSSEObs.value,
(sseData) => {
if (!sseData) return;
if (['message:created', 'message:updated'].includes(sseData.type)) {
const { data } = sseData;
messageMapObs.value[data.id] = data;
if (sseData.type === 'message:created') {
notification.info({
message: (
<div
style={{
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap',
}}
>
{data.title}
</div>
),
description: data.content.slice(0, 100) + (data.content.length > 100 ? '...' : ''),
onClick: () => {
inboxVisible.value = true;
selectedChannelNameObs.value = data.channelName;
notification.destroy();
},
});
}
fetchChannels({ filter: { name: data.channelName } });
updateUnreadMsgsCount();
}
},
);
export const startMsgSSEStreamWithRetry = async () => {
let retryTimes = 0;
const clientId = uid();
const createMsgSSEConnection = async (clientId: string) => {
const apiClient = getAPIClient();
const res = await apiClient.silent().request({
url: 'myInAppMessages:sse',
method: 'get',
headers: {
Accept: 'text/event-stream',
},
params: {
id: clientId,
},
responseType: 'stream',
adapter: 'fetch',
});
const stream = res.data;
const reader = stream.pipeThrough(new TextDecoderStream()).getReader();
retryTimes = 0;
// eslint-disable-next-line no-constant-condition
while (true) {
const { value, done } = await reader.read();
if (done) break;
const messages = value.split('\n\n').filter(Boolean);
for (const message of messages) {
const sseData: SSEData = JSON.parse(message.replace(/^data:\s*/, '').trim());
liveSSEObs.value = sseData;
}
}
};
const connectWithRetry = async () => {
try {
await createMsgSSEConnection(clientId);
} catch (error) {
console.error('Error during stream:', error.message);
const nextDelay = retryTimes < 6 ? 1000 * Math.pow(2, retryTimes) : 60000;
retryTimes++;
setTimeout(() => {
connectWithRetry();
}, nextDelay);
return { error };
}
};
connectWithRetry();
};

View File

@ -0,0 +1,11 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { observable } from '@formily/reactive';
export const userIdObs = observable<{ value: number | null }>({ value: null });

View File

@ -0,0 +1,15 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { APIClient } from '@nocobase/client';
let apiClient: APIClient;
export const setAPIClient = (apiClientTarget: APIClient) => {
apiClient = apiClientTarget;
};
export const getAPIClient = () => apiClient;

View File

@ -0,0 +1,11 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
export * from './server';
export { default } from './server';

View File

@ -0,0 +1,21 @@
{
"Inbox": "Inbox",
"Message": "Message",
"Loading more": "Loading more",
"Detail": "Detail",
"Content": "Content",
"Datetime": "Datetime",
"Status": "Status",
"All": "All",
"Read": "Read",
"Unread": "Unread",
"In-app message": "In-app message",
"Receivers": "Receivers",
"Channel name": "Channel name",
"Message group name": "Message group name",
"Message title": "Message title",
"Message content": "Message content",
"Inapp Message": "Inapp Message",
"Detail URL": "Detail URL",
"Support two types of links in nocobase: internal links and external links. If using an internal link, the link starts with '/', for example, '/admin/page'. If using an external link, the link starts with 'http', for example, 'https://example.com'.": "Support two types of links in nocobase: internal links and external links. If using an internal link, the link starts with '/', for example, '/admin/page'. If using an external link, the link starts with 'http', for example, 'https://example.com'."
}

View File

@ -0,0 +1,27 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { i18n } from '@nocobase/client';
import { useTranslation } from 'react-i18next';
export const NAMESPACE = 'notification-in-app-message';
export function lang(key: string) {
return i18n.t(key, { ns: NAMESPACE });
}
export function generateNTemplate(key: string) {
return `{{t('${key}', { ns: '${NAMESPACE}', nsMode: 'fallback' })}}`;
}
export function useLocalTranslation() {
return useTranslation([NAMESPACE,'client'],{
nsMode: 'fallback',
});
}

View File

@ -0,0 +1,20 @@
{
"Inbox": "收信箱",
"Message": "消息",
"Loading more": "加载更多",
"Detail": "详情",
"Content": "内容",
"Datetime": "时间",
"Status": "状态",
"Read": "已读",
"Unread": "未读",
"All": "全部",
"In-app message": "站内信",
"Receivers": "接收人",
"Message group name": "消息分组名称",
"Message title": "消息标题",
"Message content": "消息内容",
"Inapp Message": "站内信",
"Detail URL": "详情链接",
"Support two types of links in nocobase: internal links and external links. If using an internal link, the link starts with '/', for example, '/admin/page'. If using an external link, the link starts with 'http', for example, 'https://example.com'.": "nocobase支持两种链接类型内部链接和外部链接。如果使用内部链接链接以'/'开头,例如,'/admin/page'。如果使用外部链接,链接以'http'开头,例如,'https://example.com'。"
}

View File

@ -0,0 +1,146 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Application } from '@nocobase/server';
import { SendFnType, BaseNotificationChannel } from '@nocobase/plugin-notification-manager';
import { InAppMessageFormValues } from '../types';
import { PassThrough } from 'stream';
import { InAppMessagesDefinition as MessagesDefinition } from '../types';
import { parseUserSelectionConf } from './parseUserSelectionConf';
import defineMyInAppMessages from './defineMyInAppMessages';
import defineMyInAppChannels from './defineMyInAppChannels';
type UserID = string;
type ClientID = string;
export default class InAppNotificationChannel extends BaseNotificationChannel {
userClientsMap: Record<UserID, Record<ClientID, PassThrough>>;
constructor(protected app: Application) {
super(app);
this.userClientsMap = {};
}
async load() {
this.onMessageCreatedOrUpdated();
this.defineActions();
}
onMessageCreatedOrUpdated = async () => {
this.app.db.on(`${MessagesDefinition.name}.afterUpdate`, async (model, options) => {
const userId = model.userId;
this.sendDataToUser(userId, { type: 'message:updated', data: model.dataValues });
});
this.app.db.on(`${MessagesDefinition.name}.afterCreate`, async (model, options) => {
const userId = model.userId;
this.sendDataToUser(userId, { type: 'message:created', data: model.dataValues });
});
};
addClient = (userId: UserID, clientId: ClientID, stream: PassThrough) => {
if (!this.userClientsMap[userId]) {
this.userClientsMap[userId] = {};
}
this.userClientsMap[userId][clientId] = stream;
};
getClient = (userId: UserID, clientId: ClientID) => {
return this.userClientsMap[userId]?.[clientId];
};
removeClient = (userId: UserID, clientId: ClientID) => {
if (this.userClientsMap[userId]) {
delete this.userClientsMap[userId][clientId];
}
};
sendDataToUser(userId: UserID, message: { type: string; data: any }) {
const clients = this.userClientsMap[userId];
if (clients) {
for (const clientId in clients) {
const stream = clients[clientId];
stream.write(
`data: ${JSON.stringify({
type: message.type,
data: {
...message.data,
title: message.data.title.slice(0, 30),
content: message.data.content.slice(0, 105),
},
})}\n\n`,
);
}
}
}
saveMessageToDB = async ({
content,
status,
userId,
title,
channelName,
receiveTimestamp,
options = {},
}: {
content: string;
userId: number;
title: string;
channelName: string;
status: 'read' | 'unread';
receiveTimestamp?: number;
options?: Record<string, any>;
}): Promise<any> => {
const messagesRepo = this.app.db.getRepository(MessagesDefinition.name);
const message = await messagesRepo.create({
values: {
content,
title,
channelName,
status,
userId,
receiveTimestamp: receiveTimestamp ?? Date.now(),
options,
},
});
return message;
};
send: SendFnType<InAppMessageFormValues> = async (params) => {
const { channel, message, receivers } = params;
let userIds: number[];
const { content, title, options = {} } = message;
const userRepo = this.app.db.getRepository('users');
if (receivers?.type === 'userId') {
userIds = receivers.value;
} else {
userIds = (await parseUserSelectionConf(message.receivers, userRepo)).map((i) => parseInt(i));
}
await Promise.all(
userIds.map(async (userId) => {
await this.saveMessageToDB({
title,
content,
status: 'unread',
userId,
channelName: channel.name,
options,
});
}),
);
return { status: 'success', message };
};
defineActions() {
defineMyInAppMessages({
app: this.app,
addClient: this.addClient,
removeClient: this.removeClient,
getClient: this.getClient,
});
defineMyInAppChannels({ app: this.app });
this.app.acl.allow('myInAppMessages', '*', 'loggedIn');
this.app.acl.allow('myInAppChannels', '*', 'loggedIn');
this.app.acl.allow('notificationInAppMessages', '*', 'loggedIn');
}
}

View File

@ -0,0 +1,57 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { uid } from '@nocobase/utils';
import { randomUUID } from 'crypto';
export async function createMessages({ messagesRepo }, { unreadNum, readNum, channelName, startTimeStamp, userId }) {
const unreadMessages = Array.from({ length: unreadNum }, (_, idx) => {
return {
id: randomUUID(),
channelName,
userId,
status: 'unread',
title: `unread-${idx}`,
content: 'unread',
receiveTimestamp: startTimeStamp - idx * 1000,
options: {
url: '/admin/pages',
},
};
});
const readMessages = Array.from({ length: readNum }, (_, idx) => {
return {
id: randomUUID(),
channelName,
userId,
status: 'read',
title: `read-${idx}`,
content: 'unread',
receiveTimestamp: startTimeStamp - idx - 100000000,
options: {
url: '/admin/pages',
},
};
});
const totalMessages = [...unreadMessages, ...readMessages];
await messagesRepo.create({
values: totalMessages,
});
}
export async function createChannels({ channelsRepo }, { totalNum }) {
const channelsData = Array.from({ length: totalNum }).map((val, idx) => {
return {
name: `s_${uid()}`,
title: `站内信渠道-${idx}`,
notificationType: 'in-app-message',
};
});
await channelsRepo.create({ values: channelsData });
return channelsData;
}

View File

@ -0,0 +1,30 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Database } from '@nocobase/database';
import { createMockServer } from '@nocobase/test';
import { ChannelsCollectionDefinition as ChannelsDefinition } from '@nocobase/plugin-notification-manager';
import { InAppMessagesDefinition as MessagesDefinition } from '../../../types';
import { createChannels, createMessages } from './db-funcs';
const database = new Database({
dialect: 'postgres',
database: 'nocobase_notifications_inapp',
username: 'nocobase',
password: 'nocobase',
host: 'localhost',
port: 5432,
});
export const initServer = async () => {
const app = await createMockServer({
plugins: ['users', 'auth', 'notification-manager', 'notification-in-app'],
});
return app;
};

View File

@ -0,0 +1,8 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/

View File

@ -0,0 +1,150 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import Database from '@nocobase/database';
import { createMockServer, MockServer } from '@nocobase/test';
import { InAppMessagesDefinition as MessagesDefinition } from '../../types';
import defineMyInAppChannels from '../defineMyInAppChannels';
import { ChannelsCollectionDefinition as ChannelsDefinition } from '@nocobase/plugin-notification-manager';
import { createMessages } from './mock/db-funcs';
import defineMyInAppMessages from '../defineMyInAppMessages';
describe('inapp message channels', () => {
let app: MockServer;
let db: Database;
let UserRepo;
let users;
let userAgents;
let channelsRepo;
let messagesRepo;
let currUserAgent;
let currUserId;
beforeEach(async () => {
app = await createMockServer({
plugins: ['users', 'auth', 'notification-manager', 'notification-in-app-message'],
});
await app.pm.get('auth')?.install();
db = app.db;
UserRepo = db.getCollection('users').repository;
channelsRepo = db.getRepository(ChannelsDefinition.name);
messagesRepo = db.getRepository(MessagesDefinition.name);
users = await UserRepo.create({
values: [
{ id: 2, nickname: 'a', roles: [{ name: 'root' }] },
{ id: 3, nickname: 'b' },
],
});
userAgents = users.map((user) => app.agent().login(user));
currUserAgent = userAgents[0];
currUserId = users[0].id;
});
afterEach(async () => {
await app.destroy();
});
describe('myInappChannels', async () => {
beforeEach(async () => {
await channelsRepo.destroy({ truncate: true });
await messagesRepo.destroy({ truncate: true });
});
test('user can get own channels and messages', async () => {
defineMyInAppChannels({ app });
defineMyInAppMessages({ app, addClient: () => null, removeClient: () => null });
const channelsRes = await channelsRepo.create({
values: [
{
title: '测试渠道2(userId=2)',
notificationType: 'in-app-message',
},
{
title: '测试渠道3(userId=3)',
notificationType: 'in-app-message',
},
],
});
await createMessages(
{ messagesRepo },
{ unreadNum: 2, readNum: 2, channelName: channelsRes[0].name, startTimeStamp: Date.now(), userId: users[0].id },
);
await createMessages(
{ messagesRepo },
{ unreadNum: 2, readNum: 2, channelName: channelsRes[0].name, startTimeStamp: Date.now(), userId: users[1].id },
);
const res = await userAgents[0].resource('myInAppChannels').list();
expect(res.body.data.length).toBe(1);
const myMessages = await userAgents[0].resource('myInAppMessages').list();
expect(myMessages.body.data.messages.length).toBe(4);
});
test('filter channel by status', async () => {
const channels = await channelsRepo.create({
values: [
{
title: 'read_channel',
notificationType: 'in-app-message',
},
{
title: 'unread_channel',
notificationType: 'in-app-message',
},
{
title: 'mix_channel',
notificationType: 'in-app-message',
},
],
});
const allReadChannel = channels.find((channel) => channel.title === 'read_channel');
const allUnreadChannel = channels.find((channel) => channel.title === 'unread_channel');
const mixChannel = channels.find((channel) => channel.title === 'mix_channel');
await createMessages(
{ messagesRepo },
{ unreadNum: 0, readNum: 4, channelName: allReadChannel.name, startTimeStamp: Date.now(), userId: currUserId },
);
await createMessages(
{ messagesRepo },
{
unreadNum: 4,
readNum: 0,
channelName: allUnreadChannel.name,
startTimeStamp: Date.now(),
userId: currUserId,
},
);
await createMessages(
{ messagesRepo },
{
unreadNum: 2,
readNum: 2,
channelName: mixChannel.name,
startTimeStamp: Date.now(),
userId: currUserId,
},
);
const readChannelsRes = await currUserAgent.resource('myInAppChannels').list({ filter: { status: 'read' } });
const unreadChannelsRes = await currUserAgent.resource('myInAppChannels').list({ filter: { status: 'unread' } });
const allChannelsRes = await currUserAgent.resource('myInAppChannels').list({ filter: { status: 'all' } });
[allReadChannel, mixChannel].forEach((channel) => {
expect(readChannelsRes.body.data.map((channel) => channel.name)).toContain(channel.name);
});
[allUnreadChannel, mixChannel].forEach((channel) => {
expect(unreadChannelsRes.body.data.map((channel) => channel.name)).toContain(channel.name);
});
expect(allChannelsRes.body.data.length).toBe(3);
});
// test('channel last receive timestamp filter', () => {
// const currentTS = Date.now();
// });
});
});

View File

@ -0,0 +1,12 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { messageCollection } from '../../types/messages';
export default messageCollection;

View File

@ -0,0 +1,148 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Application } from '@nocobase/server';
import { Op, Sequelize } from 'sequelize';
import { ChannelsCollectionDefinition as ChannelsDefinition } from '@nocobase/plugin-notification-manager';
import { InAppMessagesDefinition as MessagesDefinition } from '../types';
export default function defineMyInAppChannels({ app }: { app: Application }) {
app.resourceManager.define({
name: 'myInAppChannels',
actions: {
list: {
handler: async (ctx) => {
const { filter = {}, limit = 30 } = ctx.action?.params ?? {};
const messagesCollection = app.db.getCollection(MessagesDefinition.name);
const messagesTableName = messagesCollection.getRealTableName(true);
const channelsCollection = app.db.getCollection(ChannelsDefinition.name);
const channelsTableAliasName = app.db.sequelize.getQueryInterface().quoteIdentifier(channelsCollection.name);
const channelsFieldName = {
name: channelsCollection.getRealFieldName(ChannelsDefinition.fieldNameMap.name, true),
};
const messagesFieldName = {
channelName: messagesCollection.getRealFieldName(MessagesDefinition.fieldNameMap.channelName, true),
status: messagesCollection.getRealFieldName(MessagesDefinition.fieldNameMap.status, true),
userId: messagesCollection.getRealFieldName(MessagesDefinition.fieldNameMap.userId, true),
receiveTimestamp: messagesCollection.getRealFieldName(
MessagesDefinition.fieldNameMap.receiveTimestamp,
true,
),
title: messagesCollection.getRealFieldName(MessagesDefinition.fieldNameMap.title, true),
};
const userId = ctx.state.currentUser.id;
const userFilter = userId
? {
name: {
[Op.in]: Sequelize.literal(`(
SELECT messages.${messagesFieldName.channelName}
FROM ${messagesTableName} AS messages
WHERE
messages.${messagesFieldName.userId} = ${userId}
)`),
},
}
: null;
const latestMsgReceiveTimestampSQL = `(
SELECT messages.${messagesFieldName.receiveTimestamp}
FROM ${messagesTableName} AS messages
WHERE
messages.${messagesFieldName.channelName} = ${channelsTableAliasName}.${channelsFieldName.name}
ORDER BY messages.${messagesFieldName.receiveTimestamp} DESC
LIMIT 1
)`;
const latestMsgReceiveTSFilter = filter?.latestMsgReceiveTimestamp?.$lt
? Sequelize.literal(`${latestMsgReceiveTimestampSQL} < ${filter.latestMsgReceiveTimestamp.$lt}`)
: null;
const channelIdFilter = filter?.id ? { id: filter.id } : null;
const statusMap = {
all: 'read|unread',
unread: 'unread',
read: 'read',
};
const filterChannelsByStatusSQL = ({ status }) => {
const sql = Sequelize.literal(`(
SELECT messages.${messagesFieldName.channelName}
FROM ${messagesTableName} AS messages
WHERE messages.${messagesFieldName.status} = '${status}'
)`);
return { name: { [Op.in]: sql } };
};
const channelStatusFilter =
filter.status === 'all' || !filter.status
? null
: filterChannelsByStatusSQL({ status: statusMap[filter.status] });
const channelsRepo = app.db.getRepository(ChannelsDefinition.name);
try {
const channelsRes = channelsRepo.find({
logging: console.log,
limit,
attributes: {
include: [
[
Sequelize.literal(`(
SELECT COUNT(*)
FROM ${messagesTableName} AS messages
WHERE
messages.${messagesFieldName.channelName} = ${channelsTableAliasName}.${channelsFieldName.name}
AND messages.${messagesFieldName.userId} = ${userId}
)`),
'totalMsgCnt',
],
[Sequelize.literal(`'${userId}'`), 'userId'],
[
Sequelize.literal(`(
SELECT COUNT(*)
FROM ${messagesTableName} AS messages
WHERE
messages.${messagesFieldName.channelName} = ${channelsTableAliasName}.${channelsFieldName.name}
AND messages.${messagesFieldName.status} = 'unread'
AND messages.${messagesFieldName.userId} = ${userId}
)`),
'unreadMsgCnt',
],
[Sequelize.literal(latestMsgReceiveTimestampSQL), 'latestMsgReceiveTimestamp'],
[
Sequelize.literal(`(
SELECT messages.${messagesFieldName.title}
FROM ${messagesTableName} AS messages
WHERE
messages.${messagesFieldName.channelName} = ${channelsTableAliasName}.${channelsFieldName.name}
ORDER BY messages.${messagesFieldName.receiveTimestamp} DESC
LIMIT 1
)`),
'latestMsgTitle',
],
],
},
order: [[Sequelize.literal('latestMsgReceiveTimestamp'), 'DESC']],
//@ts-ignore
where: {
[Op.and]: [userFilter, latestMsgReceiveTSFilter, channelIdFilter, channelStatusFilter].filter(Boolean),
},
});
const countRes = channelsRepo.count({
//@ts-ignore
where: {
[Op.and]: [userFilter, channelStatusFilter].filter(Boolean),
},
});
const [channels, count] = await Promise.all([channelsRes, countRes]);
ctx.body = { rows: channels, count };
} catch (error) {
console.error(error);
}
},
},
},
});
}

View File

@ -0,0 +1,107 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Application } from '@nocobase/server';
import { Op, Sequelize } from 'sequelize';
import { PassThrough } from 'stream';
import { InAppMessagesDefinition as MessagesDefinition } from '../types';
import { ChannelsCollectionDefinition as ChannelsDefinition } from '@nocobase/plugin-notification-manager';
export default function defineMyInAppMessages({
app,
addClient,
removeClient,
getClient,
}: {
app: Application;
addClient: any;
removeClient: any;
getClient: any;
}) {
const countTotalUnreadMessages = async (userId: string) => {
const messagesRepo = app.db.getRepository(MessagesDefinition.name);
const channelsCollection = app.db.getCollection(ChannelsDefinition.name);
const channelsTableName = channelsCollection.getRealTableName(true);
const channelsFieldName = {
name: channelsCollection.getRealFieldName(ChannelsDefinition.fieldNameMap.name, true),
};
const count = await messagesRepo.count({
logging: console.log,
// @ts-ignore
where: {
userId,
status: 'unread',
channelName: {
[Op.in]: Sequelize.literal(`(select ${channelsFieldName.name} from ${channelsTableName})`),
},
},
});
return count;
};
app.resourceManager.define({
name: 'myInAppMessages',
actions: {
sse: {
handler: async (ctx, next) => {
const userId = ctx.state.currentUser.id;
const clientId = ctx.action?.params?.id;
if (!clientId) return;
ctx.request.socket.setTimeout(0);
ctx.req.socket.setNoDelay(true);
ctx.req.socket.setKeepAlive(true);
ctx.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
const stream = new PassThrough();
ctx.status = 200;
ctx.body = stream;
addClient(userId, clientId, stream);
stream.on('close', () => {
removeClient(userId, clientId);
});
stream.on('error', () => {
removeClient(userId, clientId);
});
await next();
},
},
count: {
handler: async (ctx) => {
try {
const userId = ctx.state.currentUser.id;
const count = await countTotalUnreadMessages(userId);
ctx.body = { count };
} catch (error) {
console.error(error);
}
},
},
list: {
handler: async (ctx) => {
const userId = ctx.state.currentUser.id;
const messagesRepo = app.db.getRepository(MessagesDefinition.name);
const { filter = {} } = ctx.action?.params ?? {};
const messageList = await messagesRepo.find({
limit: 20,
...(ctx.action?.params ?? {}),
filter: {
...filter,
userId,
},
sort: '-receiveTimestamp',
});
ctx.body = { messages: messageList };
},
},
},
});
}

View File

@ -0,0 +1,10 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
export { default } from './plugin';

View File

@ -0,0 +1,30 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Repository } from '@nocobase/database';
export async function parseUserSelectionConf(
userSelectionConfig: Array<Record<any, any> | string>,
UserRepo: Repository,
) {
const SelectionConfigs = userSelectionConfig.flat().filter(Boolean);
const users = new Set<string>();
for (const item of SelectionConfigs) {
if (typeof item === 'object') {
const result = await UserRepo.find({
...item,
fields: ['id'],
});
result.forEach((item) => users.add(item.id));
} else {
users.add(item);
}
}
return [...users];
}

View File

@ -0,0 +1,37 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Plugin } from '@nocobase/server';
import { inAppTypeName } from '../types';
import NotificationsServerPlugin from '@nocobase/plugin-notification-manager';
import InAppNotificationChannel from './InAppNotificationChannel';
const NAMESPACE = 'notification-in-app';
export class PluginNotificationInAppServer extends Plugin {
async afterAdd() {}
async beforeLoad() {}
async load() {
const notificationServer = this.pm.get(NotificationsServerPlugin) as NotificationsServerPlugin;
const instance = new InAppNotificationChannel(this.app);
instance.load();
notificationServer.registerChannelType({ type: inAppTypeName, Channel: InAppNotificationChannel });
}
async install() {}
async afterEnable() {}
async afterDisable() {}
async remove() {}
}
export default PluginNotificationInAppServer;

View File

@ -0,0 +1,78 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { ChannelsDefinition } from '.';
import { CollectionOptions } from '@nocobase/client';
export const channelsCollection: CollectionOptions = {
name: ChannelsDefinition.name,
title: 'in-app messages',
fields: [
{
name: ChannelsDefinition.fieldNameMap.id,
type: 'uuid',
primaryKey: true,
allowNull: false,
interface: 'uuid',
uiSchema: {
type: 'string',
title: '{{t("ID")}}',
'x-component': 'Input',
'x-read-pretty': true,
},
},
{
name: ChannelsDefinition.fieldNameMap.senderId,
type: 'uuid',
allowNull: false,
interface: 'uuid',
uiSchema: {
type: 'string',
title: '{{t("Sender ID")}}',
'x-component': 'Input',
'x-read-pretty': true,
},
},
{
name: 'userId',
type: 'bigInt',
uiSchema: {
type: 'number',
'x-component': 'Input',
title: '{{t("User ID")}}',
required: true,
},
},
{
name: ChannelsDefinition.fieldNameMap.title,
type: 'text',
interface: 'input',
uiSchema: {
type: 'string',
'x-component': 'Input',
title: '{{t("Title")}}',
required: true,
},
},
{
name: 'latestMsgId',
type: 'string',
interface: 'input',
},
],
};
export type MsgGroup = {
id: string;
title: string;
userId: string;
unreadMsgCnt: number;
lastMessageReceiveTime: string;
lastMessageTitle: string;
};

View File

@ -0,0 +1,69 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
export interface Channel {
name: string;
title: string;
userId: string;
unreadMsgCnt: number;
totalMsgCnt: number;
latestMsgReceiveTimestamp: number;
latestMsgTitle: string;
}
export interface Message {
id: string;
title: string;
userId: string;
channelName: string;
content: string;
receiveTimestamp: number;
status: 'read' | 'unread';
url: string;
options: Record<string, any>;
}
export type SSEData = {
type: 'message:created';
data: Message;
};
export interface InAppMessageFormValues {
receivers: string[];
content: string;
senderName: string;
senderId: string;
url: string;
title: string;
options: Record<string, any>;
}
export const InAppMessagesDefinition = {
name: 'notificationInAppMessages',
fieldNameMap: {
id: 'id',
channelName: 'channelName',
userId: 'userId',
content: 'content',
status: 'status',
title: 'title',
receiveTimestamp: 'receiveTimestamp',
options: 'options',
},
} as const;
export const ChannelsDefinition = {
name: 'notificationInAppChannels',
fieldNameMap: {
id: 'id',
senderId: 'senderId',
title: 'title',
lastMsgId: 'lastMsgId',
},
} as const;
export const inAppTypeName = 'in-app-message';

View File

@ -0,0 +1,105 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { CollectionOptions } from '@nocobase/client';
import { InAppMessagesDefinition, ChannelsDefinition } from './index';
export const messageCollection: CollectionOptions = {
name: InAppMessagesDefinition.name,
title: 'in-app messages',
fields: [
{
name: InAppMessagesDefinition.fieldNameMap.id,
type: 'uuid',
primaryKey: true,
allowNull: false,
interface: 'uuid',
uiSchema: {
type: 'string',
title: '{{t("ID")}}',
'x-component': 'Input',
'x-read-pretty': true,
},
},
{
name: InAppMessagesDefinition.fieldNameMap.userId,
type: 'bigInt',
uiSchema: {
type: 'number',
'x-component': 'Input',
title: '{{t("User ID")}}',
required: true,
},
},
{
name: 'channel',
type: 'belongsTo',
interface: 'm2o',
target: 'notificationChannels',
targetKey: 'name',
foreignKey: InAppMessagesDefinition.fieldNameMap.channelName,
uiSchema: {
type: 'string',
'x-component': 'AssociationField',
title: '{{t("Channel")}}',
},
},
{
name: InAppMessagesDefinition.fieldNameMap.title,
type: 'text',
uiSchema: {
type: 'string',
'x-component': 'Input',
title: '{{t("Title")}}',
required: true,
},
},
{
name: InAppMessagesDefinition.fieldNameMap.content,
type: 'text',
interface: 'string',
uiSchema: {
type: 'string',
title: '{{t("Content")}}',
'x-component': 'Input',
},
},
{
name: InAppMessagesDefinition.fieldNameMap.status,
type: 'string',
uiSchema: {
type: 'string',
'x-component': 'Input',
title: '{{t("Status")}}',
required: true,
},
},
{
name: 'createdAt',
type: 'date',
interface: 'createdAt',
field: 'createdAt',
uiSchema: {
type: 'datetime',
title: '{{t("Created at")}}',
'x-component': 'DatePicker',
'x-component-props': {},
'x-read-pretty': true,
},
},
{
name: InAppMessagesDefinition.fieldNameMap.receiveTimestamp,
type: 'bigInt',
},
{
name: InAppMessagesDefinition.fieldNameMap.options,
type: 'json',
},
],
};

View File

@ -0,0 +1,21 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
export type SSEData = {
type: 'message:created';
data: {
id: string;
title: string;
content: string;
userId: string;
receiveTimestamp: number;
channelName: string;
status: 'read' | 'unread';
};
};

View File

@ -0,0 +1,7 @@
{
"extends": ["../../../../tsconfig.json"],
"compilerOptions": {
"strictNullChecks": true,
"allowJs": false
}
}

View File

@ -56,4 +56,5 @@ export class PluginNotificationManagerClient extends Plugin {
export { NotificationVariableContext, NotificationVariableProvider, useNotificationVariableOptions } from './hooks';
export { MessageConfigForm } from './manager/message/components/MessageConfigForm';
export { ContentConfigForm } from './manager/message/components/ContentConfigForm';
export default PluginNotificationManagerClient;

View File

@ -31,6 +31,8 @@ import {
useEditActionProps,
useEditFormProps,
useNotificationTypes,
useRecordDeleteActionProps,
useRecordEditActionProps,
} from '../hooks';
import { channelsSchema, createFormSchema } from '../schemas';
import { ConfigForm } from './ConfigForm';
@ -48,7 +50,7 @@ const AddNew = () => {
const [visible, setVisible] = useState(false);
const { NotificationTypeNameProvider, name, setName } = useNotificationTypeNameProvider();
const api = useAPIClient();
const channelTypes = useChannelTypes();
const channelTypes = useChannelTypes().filter((item) => !(item.meta?.creatable === false));
const items =
channelTypes.length === 0
? [
@ -140,6 +142,8 @@ export const ChannelManager = () => {
useCloseActionProps,
useEditFormProps,
useCreateFormProps,
useRecordDeleteActionProps,
useRecordEditActionProps,
}}
/>
</NotificationTypesContext.Provider>

View File

@ -17,6 +17,7 @@ import {
useCollection,
useCollectionRecordData,
useDataBlockRequest,
useDestroyActionProps,
useDataBlockResource,
usePlugin,
} from '@nocobase/client';
@ -33,7 +34,6 @@ export const useCreateActionProps = () => {
const form = useForm();
const resource = useDataBlockResource();
const { service } = useBlockRequestContext();
const collection = useCollection();
return {
type: 'primary',
async onClick(e?, callBack?) {
@ -104,6 +104,26 @@ export const useEditFormProps = () => {
form,
};
};
export const useRecordEditActionProps = () => {
const recordData = useCollectionRecordData();
const editable = recordData?.meta?.editable;
const style: React.CSSProperties = {};
if (editable === false) {
style.display = 'none';
}
return { style };
};
export const useRecordDeleteActionProps = () => {
const recordData = useCollectionRecordData();
const deletable = recordData?.meta?.deletable;
const style: React.CSSProperties = {};
const destroyProps = useDestroyActionProps();
if (deletable === false) {
style.display = 'none';
}
return { ...destroyProps, style };
};
export const useCreateFormProps = () => {
const ctx = useActionContext();

View File

@ -177,6 +177,7 @@ export const channelsSchema: ISchema = {
openMode: 'drawer',
icon: 'EditOutlined',
},
'x-use-component-props': 'useRecordEditActionProps',
'x-decorator': 'Space',
properties: {
drawer: {
@ -212,7 +213,7 @@ export const channelsSchema: ISchema = {
title: '{{t("Delete")}}',
'x-decorator': 'Space',
'x-component': 'Action.Link',
'x-use-component-props': 'useDestroyActionProps',
'x-use-component-props': 'useRecordDeleteActionProps',
'x-component-props': {
confirm: {
title: "{{t('Delete record')}}",

View File

@ -16,10 +16,11 @@ export type RegisterChannelOptions = {
components: {
ChannelConfigForm: ComponentType;
MessageConfigForm?: ComponentType<{ variableOptions: any }>;
ContentConfigForm?: ComponentType<{ variableOptions?: any }>;
};
meta?: {
creatable?: boolean;
eidtable?: boolean;
editable?: boolean;
deletable?: boolean;
};
};

View File

@ -0,0 +1,23 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { withDynamicSchemaProps } from '@nocobase/client';
import { observer } from '@formily/react';
import { useChannelTypeMap } from '../../../../hooks';
export const ContentConfigForm = withDynamicSchemaProps(
observer<{ variableOptions: any; channelType: string }>(
({ variableOptions, channelType }) => {
const channelTypeMap = useChannelTypeMap();
const { ContentConfigForm = () => null } = (channelType ? channelTypeMap[channelType] : {}).components || {};
return <ContentConfigForm variableOptions={variableOptions} />;
},
{ displayName: 'ContentConfigForm' },
),
);

View File

@ -7,30 +7,24 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React, { useState, useContext, useEffect } from 'react';
import { ArrayItems } from '@formily/antd-v5';
import { SchemaComponent, css } from '@nocobase/client';
import { onFieldValueChange } from '@formily/core';
import { observer, useField, useForm, useFormEffects } from '@formily/react';
import { useAPIClient, Variable } from '@nocobase/client';
import React, { useState, useEffect } from 'react';
import { SchemaComponent } from '@nocobase/client';
import { observer, useField } from '@formily/react';
import { useAPIClient } from '@nocobase/client';
import { useChannelTypeMap } from '../../../../hooks';
import { useNotificationTranslation } from '../../../../locale';
import { COLLECTION_NAME } from '../../../../../constant';
import { UsersAddition } from '../ReceiverConfigForm/Users/UsersAddition';
import { UsersSelect } from '../ReceiverConfigForm/Users/Select';
export const MessageConfigForm = observer<{ variableOptions: any }>(
({ variableOptions }) => {
const field = useField();
const form = useForm();
const { channelName, receiverType } = field.form.values;
const [providerName, setProviderName] = useState(null);
const { channelName } = field.form.values;
const [channelType, setChannelType] = useState(null);
const { t } = useNotificationTranslation();
const api = useAPIClient();
useEffect(() => {
const onChannelChange = async () => {
if (!channelName) {
setProviderName(null);
setChannelType(null);
return;
}
const { data } = await api.request({
@ -40,25 +34,13 @@ export const MessageConfigForm = observer<{ variableOptions: any }>(
filterByTk: channelName,
},
});
setProviderName(data?.data?.notificationType);
setChannelType(data?.data?.notificationType);
};
onChannelChange();
}, [channelName, api]);
useFormEffects(() => {
onFieldValueChange('receiverType', (value) => {
field.form.values.receivers = [];
});
});
// useEffect(() => {
// field.form.values.receivers = [];
// }, [field.form.values, receiverType]);
const providerMap = useChannelTypeMap();
const { MessageConfigForm = () => null } = (providerName ? providerMap[providerName] : {}).components || {};
const ReceiverInputComponent = receiverType === 'user' ? 'UsersSelect' : 'VariableInput';
const ReceiverAddition = receiverType === 'user' ? UsersAddition : ArrayItems.Addition;
const channelTypeMap = useChannelTypeMap();
const { MessageConfigForm = () => null } = (channelType ? channelTypeMap[channelType] : {}).components || {};
const createMessageFormSchema = {
type: 'void',
properties: {
@ -93,13 +75,7 @@ export const MessageConfigForm = observer<{ variableOptions: any }>(
},
},
};
return (
<SchemaComponent
schema={createMessageFormSchema}
components={{ MessageConfigForm, ReceiverAddition, UsersSelect, ArrayItems, VariableInput: Variable.Input }}
scope={{ t }}
/>
);
return <SchemaComponent schema={createMessageFormSchema} components={{ MessageConfigForm }} scope={{ t }} />;
},
{ displayName: 'MessageConfigForm' },
);

View File

@ -8,16 +8,20 @@
*/
import { COLLECTION_NAME } from '../constant';
import { CollectionOptions } from '@nocobase/client';
const channelCollection: CollectionOptions = {
export default {
name: COLLECTION_NAME.channels,
autoGenId: false,
filterTargetKey: 'name',
autoGenId: false,
createdAt: true,
createdBy: true,
updatedAt: true,
updatedBy: true,
fields: [
{
name: 'name',
type: 'uid',
prefix: 's_',
primaryKey: true,
interface: 'input',
uiSchema: {
@ -50,6 +54,11 @@ const channelCollection: CollectionOptions = {
'x-component': 'ConfigForm',
},
},
{
name: 'meta',
type: 'json',
interface: 'json',
},
{
interface: 'input',
type: 'string',
@ -72,55 +81,5 @@ const channelCollection: CollectionOptions = {
title: '{{t("Description")}}',
},
},
{
name: 'CreatedAt',
type: 'date',
interface: 'createdAt',
field: 'createdAt',
uiSchema: {
type: 'datetime',
title: '{{t("Created at")}}',
'x-component': 'DatePicker',
'x-component-props': {},
'x-read-pretty': true,
},
},
{
name: 'createdBy',
type: 'belongsTo',
interface: 'createdBy',
description: null,
parentKey: null,
reverseKey: null,
target: 'users',
foreignKey: 'createdById',
uiSchema: {
type: 'object',
title: '{{t("Created by")}}',
'x-component': 'AssociationField',
'x-component-props': {
fieldNames: {
value: 'id',
label: 'nickname',
},
},
'x-read-pretty': true,
},
targetKey: 'id',
},
{
name: 'updatedAt',
type: 'date',
interface: 'updatedAt',
field: 'updatedAt',
uiSchema: {
type: 'string',
title: '{{t("Last updated at")}}',
'x-component': 'DatePicker',
'x-component-props': {},
'x-read-pretty': true,
},
},
],
};
export default channelCollection;

View File

@ -7,10 +7,9 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { CollectionOptions } from '@nocobase/client';
import { COLLECTION_NAME } from '../constant';
const collectionOption: CollectionOptions = {
export default {
name: COLLECTION_NAME.logs,
title: 'MessageLogs',
fields: [
@ -132,5 +131,3 @@ const collectionOption: CollectionOptions = {
},
],
};
export default collectionOption;

View File

@ -13,3 +13,10 @@ export enum COLLECTION_NAME {
messages = 'messages',
logs = 'notificationSendLogs',
}
export const ChannelsCollectionDefinition = {
name: COLLECTION_NAME.channels,
fieldNameMap: {
name: 'name',
},
};

View File

@ -44,7 +44,6 @@ describe('notification manager server', () => {
test('create channel', async () => {
class TestNotificationMailServer {
async send({ message, channel }) {
console.log('senddddd', message, channel);
expect(channel.options.test).toEqual(1);
}
}

View File

@ -8,12 +8,13 @@
*/
import { Application } from '@nocobase/server';
import { ChannelOptions } from './types';
import { ChannelOptions, ReceiversOptions } from './types';
export abstract class BaseNotificationChannel<Message = any> {
constructor(protected app: Application) {}
abstract send(params: {
channel: ChannelOptions;
message: Message;
receivers?: ReceiversOptions;
}): Promise<{ message: Message; status: 'success' | 'fail'; reason?: string }>;
}

View File

@ -9,5 +9,6 @@
export { BaseNotificationChannel } from './base-notification-channel';
export { default } from './plugin';
export { COLLECTION_NAME, ChannelsCollectionDefinition } from '../constant';
export * from './types';

View File

@ -10,7 +10,13 @@
import { Registry } from '@nocobase/utils';
import { COLLECTION_NAME } from '../constant';
import PluginNotificationManagerServer from './plugin';
import type { NotificationChannelConstructor, RegisterServerTypeFnParams, SendOptions, WriteLogOptions } from './types';
import type {
NotificationChannelConstructor,
RegisterServerTypeFnParams,
SendOptions,
SendUserOptions,
WriteLogOptions,
} from './types';
export class NotificationManager implements NotificationManager {
private plugin: PluginNotificationManagerServer;
@ -29,29 +35,6 @@ export class NotificationManager implements NotificationManager {
return logsRepo.create({ values: options });
};
async parseReceivers(receiverType, receiversConfig, processor, node) {
const configAssignees = processor
.getParsedValue(node.config.assignees ?? [], node.id)
.flat()
.filter(Boolean);
const assignees = new Set();
const UserRepo = processor.options.plugin.app.db.getRepository('users');
for (const item of configAssignees) {
if (typeof item === 'object') {
const result = await UserRepo.find({
...item,
fields: ['id'],
transaction: processor.transaction,
});
result.forEach((item) => assignees.add(item.id));
} else {
assignees.add(item);
}
}
return [...assignees];
}
async send(params: SendOptions) {
this.plugin.logger.info('receive sending message request', params);
const channelsRepo = this.plugin.app.db.getRepository(COLLECTION_NAME.channels);
@ -67,7 +50,7 @@ export class NotificationManager implements NotificationManager {
const instance = new Channel(this.plugin.app);
logData.channelTitle = channel.title;
logData.notificationType = channel.notificationType;
const result = await instance.send({ message: params.message, channel });
const result = await instance.send({ message: params.message, channel, receivers: params.receivers });
logData.status = result.status;
logData.reason = result.reason;
} else {
@ -83,6 +66,14 @@ export class NotificationManager implements NotificationManager {
return logData;
}
}
async sendToUsers(options: SendUserOptions) {
const { userIds, channels, message, data } = options;
return await Promise.all(
channels.map((channelName) =>
this.send({ channelName, message, triggerFrom: 'sendToUsers', receivers: { value: userIds, type: 'userId' } }),
),
);
}
}
export default NotificationManager;

View File

@ -10,7 +10,7 @@
import type { Logger } from '@nocobase/logger';
import { Plugin } from '@nocobase/server';
import NotificationManager from './manager';
import { RegisterServerTypeFnParams, SendOptions } from './types';
import { RegisterServerTypeFnParams, SendOptions, SendUserOptions } from './types';
export class PluginNotificationManagerServer extends Plugin {
private manager: NotificationManager;
logger: Logger;
@ -22,6 +22,10 @@ export class PluginNotificationManagerServer extends Plugin {
return await this.manager.send(options);
}
async sendToUsers(options: SendUserOptions) {
return await this.manager.sendToUsers(options);
}
async afterAdd() {
this.logger = this.createLogger({
dirname: 'notification-manager',

View File

@ -36,12 +36,24 @@ export type WriteLogOptions = {
export type SendFnType<Message> = (args: {
message: Message;
channel: ChannelOptions;
receivers?: ReceiversOptions;
}) => Promise<{ message: Message; status: 'success' | 'fail'; reason?: string }>;
export type ReceiversOptions =
| { value: number[]; type: 'userId' }
| { value: any; type: 'channel-self-defined'; channelType: string };
export interface SendOptions {
channelName: string;
message: Record<string, any>;
triggerFrom: string;
receivers?: ReceiversOptions;
}
export interface SendUserOptions {
userIds: number[];
channels: string[];
message: Record<string, any>;
data?: Record<string, any>;
}
export type NotificationChannelConstructor = new (app: Application) => BaseNotificationChannel;

View File

@ -9,6 +9,7 @@
import React from 'react';
import { Instruction, useWorkflowVariableOptions } from '@nocobase/plugin-workflow/client';
import { MessageConfigForm } from '@nocobase/plugin-notification-manager/client';
import { NAMESPACE } from '../locale';
const LocalProvider = () => {

View File

@ -68,6 +68,7 @@
"@nocobase/plugin-workflow-sql": "1.4.0-alpha",
"@nocobase/plugin-workflow-notification": "1.4.0-alpha",
"@nocobase/server": "1.4.0-alpha",
"@nocobase/plugin-notification-in-app-message": "1.4.0-alpha",
"@nocobase/plugin-notification-email": "1.4.0-alpha",
"@nocobase/plugin-notification-manager": "1.4.0-alpha",
"cronstrue": "^2.11.0",
@ -107,6 +108,7 @@
"@nocobase/plugin-kanban",
"@nocobase/plugin-logger",
"@nocobase/plugin-notification-manager",
"@nocobase/plugin-notification-in-app-message",
"@nocobase/plugin-mobile",
"@nocobase/plugin-system-settings",
"@nocobase/plugin-ui-schema-storage",