From 056728d7ab1f52562e556591e8ea29f85df6ccaa Mon Sep 17 00:00:00 2001 From: Sheldon Guo Date: Fri, 25 Oct 2024 22:41:30 +0800 Subject: [PATCH] feat(plugin-notification-in-app) (#5254) feat: Add inapp live message notifications. --------- Co-authored-by: chenos Co-authored-by: mytharcher --- .../core/client/src/api-client/APIClient.ts | 1 - .../PinnedPluginListProvider.tsx | 2 +- packages/core/database/src/collection.ts | 12 +- packages/core/server/src/helper.ts | 2 +- .../src/client/ContentConfigForm.tsx | 95 +++++++ .../src/client/index.tsx | 2 + .../src/server/mail-server.ts | 60 +++-- .../.npmignore | 2 + .../README.md | 1 + .../client.d.ts | 2 + .../client.js | 1 + .../package.json | 25 ++ .../server.d.ts | 2 + .../server.js | 1 + .../src/client/MessageManagerProvider.tsx | 22 ++ .../src/client/client.d.ts | 249 ++++++++++++++++++ .../client/components/ContentConfigForm.tsx | 71 +++++ .../src/client/components/Inbox.tsx | 96 +++++++ .../src/client/components/InboxContent.tsx | 190 +++++++++++++ .../client/components/MessageConfigForm.tsx | 120 +++++++++ .../src/client/components/MessageList.tsx | 174 ++++++++++++ .../src/client/components/UsersAddition.tsx | 67 +++++ .../src/client/components/UsersSelect.tsx | 88 +++++++ .../src/client/components/hooks/useChat.ts | 122 +++++++++ .../src/client/index.tsx | 44 ++++ .../src/client/observables/channel.ts | 84 ++++++ .../src/client/observables/inbox.ts | 12 + .../src/client/observables/index.ts | 14 + .../src/client/observables/message.ts | 114 ++++++++ .../src/client/observables/sse.tsx | 102 +++++++ .../src/client/observables/user.ts | 11 + .../src/client/utils.ts | 15 ++ .../src/index.ts | 11 + .../src/locale/en-US.json | 21 ++ .../src/locale/index.ts | 27 ++ .../src/locale/zh-CN.json | 20 ++ .../src/server/InAppNotificationChannel.ts | 146 ++++++++++ .../src/server/__tests__/mock/db-funcs.ts | 57 ++++ .../src/server/__tests__/mock/index.ts | 30 +++ .../src/server/__tests__/mock/mock.ts | 8 + .../src/server/__tests__/server.test.ts | 150 +++++++++++ .../src/server/collections/.gitkeep | 0 .../src/server/collections/messages.ts | 12 + .../src/server/defineMyInAppChannels.ts | 148 +++++++++++ .../src/server/defineMyInAppMessages.ts | 107 ++++++++ .../src/server/index.ts | 10 + .../src/server/parseUserSelectionConf.ts | 30 +++ .../src/server/plugin.ts | 37 +++ .../src/types/channels.ts | 78 ++++++ .../src/types/index.ts | 69 +++++ .../src/types/messages.ts | 105 ++++++++ .../src/types/sse.ts | 21 ++ .../tsconfig.json | 7 + .../src/client/index.tsx | 1 + .../manager/channel/components/index.tsx | 6 +- .../src/client/manager/channel/hooks.tsx | 22 +- .../client/manager/channel/schemas/index.ts | 3 +- .../src/client/manager/channel/types.ts | 3 +- .../components/ContentConfigForm/index.tsx | 23 ++ .../components/MessageConfigForm/index.tsx | 46 +--- .../src/collections/channel.ts | 65 +---- .../src/collections/messageLog.ts | 5 +- .../src/constant.ts | 7 + .../src/server/__tests__/register.test.ts | 1 - .../src/server/base-notification-channel.ts | 3 +- .../src/server/index.ts | 1 + .../src/server/manager.ts | 41 ++- .../src/server/plugin.ts | 6 +- .../src/server/types.ts | 12 + .../src/client/NotificationInstruction.tsx | 1 + packages/presets/nocobase/package.json | 2 + 71 files changed, 2996 insertions(+), 149 deletions(-) create mode 100644 packages/plugins/@nocobase/plugin-notification-email/src/client/ContentConfigForm.tsx create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/.npmignore create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/README.md create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/client.d.ts create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/client.js create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/package.json create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/server.d.ts create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/server.js create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/MessageManagerProvider.tsx create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/client.d.ts create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/ContentConfigForm.tsx create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/Inbox.tsx create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/InboxContent.tsx create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/MessageConfigForm.tsx create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/MessageList.tsx create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/UsersAddition.tsx create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/UsersSelect.tsx create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/hooks/useChat.ts create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/index.tsx create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/observables/channel.ts create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/observables/inbox.ts create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/observables/index.ts create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/observables/message.ts create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/observables/sse.tsx create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/observables/user.ts create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/utils.ts create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/src/index.ts create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/src/locale/en-US.json create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/src/locale/index.ts create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/src/locale/zh-CN.json create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/InAppNotificationChannel.ts create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/__tests__/mock/db-funcs.ts create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/__tests__/mock/index.ts create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/__tests__/mock/mock.ts create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/__tests__/server.test.ts create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/collections/.gitkeep create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/collections/messages.ts create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/defineMyInAppChannels.ts create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/defineMyInAppMessages.ts create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/index.ts create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/parseUserSelectionConf.ts create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/plugin.ts create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/src/types/channels.ts create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/src/types/index.ts create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/src/types/messages.ts create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/src/types/sse.ts create mode 100644 packages/plugins/@nocobase/plugin-notification-in-app-message/tsconfig.json create mode 100644 packages/plugins/@nocobase/plugin-notification-manager/src/client/manager/message/components/ContentConfigForm/index.tsx diff --git a/packages/core/client/src/api-client/APIClient.ts b/packages/core/client/src/api-client/APIClient.ts index 6299359f7a..93777c64fb 100644 --- a/packages/core/client/src/api-client/APIClient.ts +++ b/packages/core/client/src/api-client/APIClient.ts @@ -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; } diff --git a/packages/core/client/src/plugin-manager/PinnedPluginListProvider.tsx b/packages/core/client/src/plugin-manager/PinnedPluginListProvider.tsx index de83465b04..cbbac0c10f 100644 --- a/packages/core/client/src/plugin-manager/PinnedPluginListProvider.tsx +++ b/packages/core/client/src/plugin-manager/PinnedPluginListProvider.tsx @@ -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); diff --git a/packages/core/database/src/collection.ts b/packages/core/database/src/collection.ts index 13dc8f51e0..f05eb39529 100644 --- a/packages/core/database/src/collection.ts +++ b/packages/core/database/src/collection.ts @@ -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; diff --git a/packages/core/server/src/helper.ts b/packages/core/server/src/helper.ts index 3f22c68679..bb528af0fd 100644 --- a/packages/core/server/src/helper.ts +++ b/packages/core/server/src/helper.ts @@ -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, diff --git a/packages/plugins/@nocobase/plugin-notification-email/src/client/ContentConfigForm.tsx b/packages/plugins/@nocobase/plugin-notification-email/src/client/ContentConfigForm.tsx new file mode 100644 index 0000000000..6e50710c87 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-email/src/client/ContentConfigForm.tsx @@ -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 ( + + ); +}; diff --git a/packages/plugins/@nocobase/plugin-notification-email/src/client/index.tsx b/packages/plugins/@nocobase/plugin-notification-email/src/client/index.tsx index 08f12711d9..ee8e2406c6 100644 --- a/packages/plugins/@nocobase/plugin-notification-email/src/client/index.tsx +++ b/packages/plugins/@nocobase/plugin-notification-email/src/client/index.tsx @@ -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, }, }); } diff --git a/packages/plugins/@nocobase/plugin-notification-email/src/server/mail-server.ts b/packages/plugins/@nocobase/plugin-notification-email/src/server/mail-server.ts index 201a59b560..f9327adddc 100644 --- a/packages/plugins/@nocobase/plugin-notification-email/src/server/mail-server.ts +++ b/packages/plugins/@nocobase/plugin-notification-email/src/server/mail-server.ts @@ -29,8 +29,10 @@ type Message = { export class MailNotificationChannel extends BaseNotificationChannel { transpoter: Transporter; async send(args): Promise { - 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 }; } diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/.npmignore b/packages/plugins/@nocobase/plugin-notification-in-app-message/.npmignore new file mode 100644 index 0000000000..65f5e8779f --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/.npmignore @@ -0,0 +1,2 @@ +/node_modules +/src diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/README.md b/packages/plugins/@nocobase/plugin-notification-in-app-message/README.md new file mode 100644 index 0000000000..c16bc42594 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/README.md @@ -0,0 +1 @@ +# @nocobase/plugin-notification-in-app-message diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/client.d.ts b/packages/plugins/@nocobase/plugin-notification-in-app-message/client.d.ts new file mode 100644 index 0000000000..6c459cbac4 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/client.d.ts @@ -0,0 +1,2 @@ +export * from './dist/client'; +export { default } from './dist/client'; diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/client.js b/packages/plugins/@nocobase/plugin-notification-in-app-message/client.js new file mode 100644 index 0000000000..b6e3be70e6 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/client.js @@ -0,0 +1 @@ +module.exports = require('./dist/client/index.js'); diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/package.json b/packages/plugins/@nocobase/plugin-notification-in-app-message/package.json new file mode 100644 index 0000000000..028aa80db6 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/package.json @@ -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" + + } +} diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/server.d.ts b/packages/plugins/@nocobase/plugin-notification-in-app-message/server.d.ts new file mode 100644 index 0000000000..c41081ddc6 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/server.d.ts @@ -0,0 +1,2 @@ +export * from './dist/server'; +export { default } from './dist/server'; diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/server.js b/packages/plugins/@nocobase/plugin-notification-in-app-message/server.js new file mode 100644 index 0000000000..972842039a --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/server.js @@ -0,0 +1 @@ +module.exports = require('./dist/server/index.js'); diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/MessageManagerProvider.tsx b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/MessageManagerProvider.tsx new file mode 100644 index 0000000000..fce4a5296d --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/MessageManagerProvider.tsx @@ -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 ( + + {props.children} + + ); +}; diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/client.d.ts b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/client.d.ts new file mode 100644 index 0000000000..4e96f83fa1 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/client.d.ts @@ -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; + 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; +} diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/ContentConfigForm.tsx b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/ContentConfigForm.tsx new file mode 100644 index 0000000000..ad1d786df4 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/ContentConfigForm.tsx @@ -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 ( + + ); +}; diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/Inbox.tsx b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/Inbox.tsx new file mode 100644 index 0000000000..75c2e042d5 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/Inbox.tsx @@ -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 =
{t('Message')}
; + const CloseIcon = ( +
+ +
+ ); + return ( + + + + + ) : null; + + const FilterTab = () => { + interface TabItem { + label: string; + key: ChannelStatus; + } + const items: Array = [ + { label: t('All'), key: 'all' }, + { label: t('Unread'), key: 'unread' }, + { label: t('Read'), key: 'read' }, + ]; + return ( + + { + channelStatusFilterObs.value = key; + fetchChannels({}); + }} + /> + + ); + }; + + return ( + + + + { + const titleColor = selectedChannelName === item.name ? token.colorPrimaryText : token.colorText; + const textColor = selectedChannelName === item.name ? token.colorPrimaryText : token.colorTextTertiary; + return ( + { + selectedChannelNameObs.value = item.name; + }} + > + +
+ {item.title} +
+
+ {dayjs(item.latestMsgReceiveTimestamp).fromNow()} +
+
+ +
+ {' '} + {item.latestMsgTitle} +
+ {channelStatusFilterObs.value !== 'read' ? ( + + ) : null} +
+
+ ); + }} + /> +
+ + {selectedChannelName ? : null} + +
+ ); +}; + +export const InboxContent = observer(InnerInboxContent); diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/MessageConfigForm.tsx b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/MessageConfigForm.tsx new file mode 100644 index 0000000000..c6c79190e3 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/MessageConfigForm.tsx @@ -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 ( + .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'.", + ), + }, + }, + }, + }, + }} + /> + ); +}; diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/MessageList.tsx b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/MessageList.tsx new file mode 100644 index 0000000000..945cc1d131 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/MessageList.tsx @@ -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(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 = {}; + 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 ( + + + {channelMapObs.value[selectedChannelName].title} + + + {messages.length === 0 && isFecthingMessageObs.value ? ( + + ) : ( + messages.map((message, index) => ( + <> + { + setHoveredMessageId(message.id); + }} + onMouseLeave={() => { + setHoveredMessageId(null); + }} + title={ + +
{ + onItemClicked(message); + }} + style={{ + fontWeight: message.status === 'unread' ? 'bold' : 'normal', + cursor: 'pointer', + width: '100%', + }} + > + {message.title} +
+
+ } + extra={ + message.options?.url ? ( + + ) : null + } + key={message.id} + > + + + {' '} + 100 ? message.content : ''} mouseEnterDelay={0.5}> + {message.content.slice(0, 100) + (message.content.length > 100 ? '...' : '')}{' '} + + + {dayjs(message.receiveTimestamp).fromNow()} + +
+ {hoveredMessageId === message.id && message.status === 'unread' ? ( + + ) : ( + {msgStatusDict[message.status]} + )} +
+
+
+
+ + )) + )} + {showMsgLoadingMoreObs.value && ( +
+ +
+ )} +
+ ); +}); diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/UsersAddition.tsx b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/UsersAddition.tsx new file mode 100644 index 0000000000..e6f1b64509 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/UsersAddition.tsx @@ -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(); + /* + 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 = ( + + ); + + return disabled ? ( + button + ) : ( + + + + + } + > + {button} + + ); +} diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/UsersSelect.tsx b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/UsersSelect.tsx new file mode 100644 index 0000000000..17efd362e4 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/UsersSelect.tsx @@ -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 + */ + +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 ? : ; +} + +function InternalUsersSelect({ value, onChange }) { + const scope = useWorkflowVariableOptions({ types: [isUserKeyField] }); + + return ( + + + + ); +} + +function UsersQuery(props) { + const field = useField(); + const options = useCollectionFilterOptions('users'); + const { token } = useToken(); + + return ( +
+ +
+ ); +} diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/hooks/useChat.ts b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/hooks/useChat.ts new file mode 100644 index 0000000000..0f139ec0d3 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/hooks/useChat.ts @@ -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; + unreadMsgCnt: number; + latestMsgReceiveTimestamp: number; + latestMsgTitle: string; +}; +const useChats = () => { + const apiClient = useAPIClient(); + const [groupMap, setGroupMap] = useState>({}); + 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; 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; 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; diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/index.tsx b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/index.tsx new file mode 100644 index 0000000000..fac9fe6dbf --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/index.tsx @@ -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; diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/observables/channel.ts b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/observables/channel.ts new file mode 100644 index 0000000000..3b806dbbd0 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/observables/channel.ts @@ -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 }>({ 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 }, +); diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/observables/inbox.ts b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/observables/inbox.ts new file mode 100644 index 0000000000..df6f4b8829 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/observables/inbox.ts @@ -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 }); diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/observables/index.ts b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/observables/index.ts new file mode 100644 index 0000000000..1d8254a68f --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/observables/index.ts @@ -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'; diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/observables/message.ts b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/observables/message.ts new file mode 100644 index 0000000000..ecfd0b1e4e --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/observables/message.ts @@ -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 }>({ 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 }) => { + 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 }; diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/observables/sse.tsx b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/observables/sse.tsx new file mode 100644 index 0000000000..75250d24c6 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/observables/sse.tsx @@ -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: ( +
+ {data.title} +
+ ), + 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(); +}; diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/observables/user.ts b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/observables/user.ts new file mode 100644 index 0000000000..44d41a1abb --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/observables/user.ts @@ -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 }); diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/utils.ts b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/utils.ts new file mode 100644 index 0000000000..5889bf0721 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/utils.ts @@ -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; diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/index.ts b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/index.ts new file mode 100644 index 0000000000..be99a2ff1a --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/index.ts @@ -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'; diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/locale/en-US.json b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/locale/en-US.json new file mode 100644 index 0000000000..3414d551ae --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/locale/en-US.json @@ -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'." +} diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/locale/index.ts b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/locale/index.ts new file mode 100644 index 0000000000..a9935f46a0 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/locale/index.ts @@ -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', + }); +} diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/locale/zh-CN.json b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/locale/zh-CN.json new file mode 100644 index 0000000000..3b3d3c6f17 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/locale/zh-CN.json @@ -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'。" +} diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/InAppNotificationChannel.ts b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/InAppNotificationChannel.ts new file mode 100644 index 0000000000..3f2b42d48e --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/InAppNotificationChannel.ts @@ -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>; + + 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; + }): Promise => { + 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 = 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'); + } +} diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/__tests__/mock/db-funcs.ts b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/__tests__/mock/db-funcs.ts new file mode 100644 index 0000000000..2dfc4e46bc --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/__tests__/mock/db-funcs.ts @@ -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; +} diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/__tests__/mock/index.ts b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/__tests__/mock/index.ts new file mode 100644 index 0000000000..1f03b84b6e --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/__tests__/mock/index.ts @@ -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; +}; diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/__tests__/mock/mock.ts b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/__tests__/mock/mock.ts new file mode 100644 index 0000000000..f3eb30f912 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/__tests__/mock/mock.ts @@ -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. + */ diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/__tests__/server.test.ts b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/__tests__/server.test.ts new file mode 100644 index 0000000000..38cf8c97eb --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/__tests__/server.test.ts @@ -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(); + // }); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/collections/.gitkeep b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/collections/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/collections/messages.ts b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/collections/messages.ts new file mode 100644 index 0000000000..41d2ef445e --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/collections/messages.ts @@ -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; diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/defineMyInAppChannels.ts b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/defineMyInAppChannels.ts new file mode 100644 index 0000000000..288029e799 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/defineMyInAppChannels.ts @@ -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); + } + }, + }, + }, + }); +} diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/defineMyInAppMessages.ts b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/defineMyInAppMessages.ts new file mode 100644 index 0000000000..be1c8847e3 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/defineMyInAppMessages.ts @@ -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 }; + }, + }, + }, + }); +} diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/index.ts b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/index.ts new file mode 100644 index 0000000000..be989de7c3 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/index.ts @@ -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'; diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/parseUserSelectionConf.ts b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/parseUserSelectionConf.ts new file mode 100644 index 0000000000..5b43f9dc10 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/parseUserSelectionConf.ts @@ -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 | string>, + UserRepo: Repository, +) { + const SelectionConfigs = userSelectionConfig.flat().filter(Boolean); + const users = new Set(); + 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]; +} diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/plugin.ts b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/plugin.ts new file mode 100644 index 0000000000..13c1b72fb6 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/plugin.ts @@ -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; diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/types/channels.ts b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/types/channels.ts new file mode 100644 index 0000000000..5bf6782dc7 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/types/channels.ts @@ -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; +}; diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/types/index.ts b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/types/index.ts new file mode 100644 index 0000000000..c6f744636d --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/types/index.ts @@ -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; +} + +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; +} + +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'; diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/types/messages.ts b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/types/messages.ts new file mode 100644 index 0000000000..231ebe7938 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/types/messages.ts @@ -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', + }, + ], +}; diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/types/sse.ts b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/types/sse.ts new file mode 100644 index 0000000000..b46c9f0413 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/types/sse.ts @@ -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'; + }; +}; diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/tsconfig.json b/packages/plugins/@nocobase/plugin-notification-in-app-message/tsconfig.json new file mode 100644 index 0000000000..689501e12a --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": ["../../../../tsconfig.json"], + "compilerOptions": { + "strictNullChecks": true, + "allowJs": false + } +} diff --git a/packages/plugins/@nocobase/plugin-notification-manager/src/client/index.tsx b/packages/plugins/@nocobase/plugin-notification-manager/src/client/index.tsx index aec4d46314..197f001da4 100644 --- a/packages/plugins/@nocobase/plugin-notification-manager/src/client/index.tsx +++ b/packages/plugins/@nocobase/plugin-notification-manager/src/client/index.tsx @@ -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; diff --git a/packages/plugins/@nocobase/plugin-notification-manager/src/client/manager/channel/components/index.tsx b/packages/plugins/@nocobase/plugin-notification-manager/src/client/manager/channel/components/index.tsx index 0294b6105a..9ea43ee9ae 100644 --- a/packages/plugins/@nocobase/plugin-notification-manager/src/client/manager/channel/components/index.tsx +++ b/packages/plugins/@nocobase/plugin-notification-manager/src/client/manager/channel/components/index.tsx @@ -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, }} /> diff --git a/packages/plugins/@nocobase/plugin-notification-manager/src/client/manager/channel/hooks.tsx b/packages/plugins/@nocobase/plugin-notification-manager/src/client/manager/channel/hooks.tsx index 962bf39360..7acd78a08e 100644 --- a/packages/plugins/@nocobase/plugin-notification-manager/src/client/manager/channel/hooks.tsx +++ b/packages/plugins/@nocobase/plugin-notification-manager/src/client/manager/channel/hooks.tsx @@ -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(); diff --git a/packages/plugins/@nocobase/plugin-notification-manager/src/client/manager/channel/schemas/index.ts b/packages/plugins/@nocobase/plugin-notification-manager/src/client/manager/channel/schemas/index.ts index e606300e43..5f8edb598a 100644 --- a/packages/plugins/@nocobase/plugin-notification-manager/src/client/manager/channel/schemas/index.ts +++ b/packages/plugins/@nocobase/plugin-notification-manager/src/client/manager/channel/schemas/index.ts @@ -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')}}", diff --git a/packages/plugins/@nocobase/plugin-notification-manager/src/client/manager/channel/types.ts b/packages/plugins/@nocobase/plugin-notification-manager/src/client/manager/channel/types.ts index b15046a2fe..2a711b03a9 100644 --- a/packages/plugins/@nocobase/plugin-notification-manager/src/client/manager/channel/types.ts +++ b/packages/plugins/@nocobase/plugin-notification-manager/src/client/manager/channel/types.ts @@ -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; }; }; diff --git a/packages/plugins/@nocobase/plugin-notification-manager/src/client/manager/message/components/ContentConfigForm/index.tsx b/packages/plugins/@nocobase/plugin-notification-manager/src/client/manager/message/components/ContentConfigForm/index.tsx new file mode 100644 index 0000000000..678cdc192f --- /dev/null +++ b/packages/plugins/@nocobase/plugin-notification-manager/src/client/manager/message/components/ContentConfigForm/index.tsx @@ -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 ; + }, + { displayName: 'ContentConfigForm' }, + ), +); diff --git a/packages/plugins/@nocobase/plugin-notification-manager/src/client/manager/message/components/MessageConfigForm/index.tsx b/packages/plugins/@nocobase/plugin-notification-manager/src/client/manager/message/components/MessageConfigForm/index.tsx index 769bbc0e93..af5c69afe8 100644 --- a/packages/plugins/@nocobase/plugin-notification-manager/src/client/manager/message/components/MessageConfigForm/index.tsx +++ b/packages/plugins/@nocobase/plugin-notification-manager/src/client/manager/message/components/MessageConfigForm/index.tsx @@ -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 ( - - ); + return ; }, { displayName: 'MessageConfigForm' }, ); diff --git a/packages/plugins/@nocobase/plugin-notification-manager/src/collections/channel.ts b/packages/plugins/@nocobase/plugin-notification-manager/src/collections/channel.ts index e4d922ad9f..a3159da955 100644 --- a/packages/plugins/@nocobase/plugin-notification-manager/src/collections/channel.ts +++ b/packages/plugins/@nocobase/plugin-notification-manager/src/collections/channel.ts @@ -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; diff --git a/packages/plugins/@nocobase/plugin-notification-manager/src/collections/messageLog.ts b/packages/plugins/@nocobase/plugin-notification-manager/src/collections/messageLog.ts index e1bd25316c..49946955aa 100644 --- a/packages/plugins/@nocobase/plugin-notification-manager/src/collections/messageLog.ts +++ b/packages/plugins/@nocobase/plugin-notification-manager/src/collections/messageLog.ts @@ -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; diff --git a/packages/plugins/@nocobase/plugin-notification-manager/src/constant.ts b/packages/plugins/@nocobase/plugin-notification-manager/src/constant.ts index 1fc00849d4..45d73649ec 100644 --- a/packages/plugins/@nocobase/plugin-notification-manager/src/constant.ts +++ b/packages/plugins/@nocobase/plugin-notification-manager/src/constant.ts @@ -13,3 +13,10 @@ export enum COLLECTION_NAME { messages = 'messages', logs = 'notificationSendLogs', } + +export const ChannelsCollectionDefinition = { + name: COLLECTION_NAME.channels, + fieldNameMap: { + name: 'name', + }, +}; diff --git a/packages/plugins/@nocobase/plugin-notification-manager/src/server/__tests__/register.test.ts b/packages/plugins/@nocobase/plugin-notification-manager/src/server/__tests__/register.test.ts index 502cb93add..fd27b38e0e 100644 --- a/packages/plugins/@nocobase/plugin-notification-manager/src/server/__tests__/register.test.ts +++ b/packages/plugins/@nocobase/plugin-notification-manager/src/server/__tests__/register.test.ts @@ -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); } } diff --git a/packages/plugins/@nocobase/plugin-notification-manager/src/server/base-notification-channel.ts b/packages/plugins/@nocobase/plugin-notification-manager/src/server/base-notification-channel.ts index c6ad3a04d2..a0842049cc 100644 --- a/packages/plugins/@nocobase/plugin-notification-manager/src/server/base-notification-channel.ts +++ b/packages/plugins/@nocobase/plugin-notification-manager/src/server/base-notification-channel.ts @@ -8,12 +8,13 @@ */ import { Application } from '@nocobase/server'; -import { ChannelOptions } from './types'; +import { ChannelOptions, ReceiversOptions } from './types'; export abstract class BaseNotificationChannel { constructor(protected app: Application) {} abstract send(params: { channel: ChannelOptions; message: Message; + receivers?: ReceiversOptions; }): Promise<{ message: Message; status: 'success' | 'fail'; reason?: string }>; } diff --git a/packages/plugins/@nocobase/plugin-notification-manager/src/server/index.ts b/packages/plugins/@nocobase/plugin-notification-manager/src/server/index.ts index 3ee1c90de3..199b7f6983 100644 --- a/packages/plugins/@nocobase/plugin-notification-manager/src/server/index.ts +++ b/packages/plugins/@nocobase/plugin-notification-manager/src/server/index.ts @@ -9,5 +9,6 @@ export { BaseNotificationChannel } from './base-notification-channel'; export { default } from './plugin'; +export { COLLECTION_NAME, ChannelsCollectionDefinition } from '../constant'; export * from './types'; diff --git a/packages/plugins/@nocobase/plugin-notification-manager/src/server/manager.ts b/packages/plugins/@nocobase/plugin-notification-manager/src/server/manager.ts index 496f56ee55..f5c61d4bc2 100644 --- a/packages/plugins/@nocobase/plugin-notification-manager/src/server/manager.ts +++ b/packages/plugins/@nocobase/plugin-notification-manager/src/server/manager.ts @@ -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; diff --git a/packages/plugins/@nocobase/plugin-notification-manager/src/server/plugin.ts b/packages/plugins/@nocobase/plugin-notification-manager/src/server/plugin.ts index bf225e1cc9..c82fd1af79 100644 --- a/packages/plugins/@nocobase/plugin-notification-manager/src/server/plugin.ts +++ b/packages/plugins/@nocobase/plugin-notification-manager/src/server/plugin.ts @@ -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', diff --git a/packages/plugins/@nocobase/plugin-notification-manager/src/server/types.ts b/packages/plugins/@nocobase/plugin-notification-manager/src/server/types.ts index 6207b16be8..6f4e24cff0 100644 --- a/packages/plugins/@nocobase/plugin-notification-manager/src/server/types.ts +++ b/packages/plugins/@nocobase/plugin-notification-manager/src/server/types.ts @@ -36,12 +36,24 @@ export type WriteLogOptions = { export type SendFnType = (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; triggerFrom: string; + receivers?: ReceiversOptions; +} + +export interface SendUserOptions { + userIds: number[]; + channels: string[]; + message: Record; + data?: Record; } export type NotificationChannelConstructor = new (app: Application) => BaseNotificationChannel; diff --git a/packages/plugins/@nocobase/plugin-workflow-notification/src/client/NotificationInstruction.tsx b/packages/plugins/@nocobase/plugin-workflow-notification/src/client/NotificationInstruction.tsx index 9d39b48569..1054c394eb 100644 --- a/packages/plugins/@nocobase/plugin-workflow-notification/src/client/NotificationInstruction.tsx +++ b/packages/plugins/@nocobase/plugin-workflow-notification/src/client/NotificationInstruction.tsx @@ -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 = () => { diff --git a/packages/presets/nocobase/package.json b/packages/presets/nocobase/package.json index d59faee262..1e01c11664 100644 --- a/packages/presets/nocobase/package.json +++ b/packages/presets/nocobase/package.json @@ -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",