mirror of
https://gitee.com/nocobase/nocobase.git
synced 2024-12-02 12:18:15 +08:00
feat(plugin-notification-in-app) (#5254)
feat: Add inapp live message notifications. --------- Co-authored-by: chenos <chenlinxh@gmail.com> Co-authored-by: mytharcher <mytharcher@gmail.com>
This commit is contained in:
parent
158ef760fc
commit
056728d7ab
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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,
|
||||
|
@ -0,0 +1,95 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { SchemaComponent, css } from '@nocobase/client';
|
||||
import { useNotifyMailTranslation } from './hooks/useTranslation';
|
||||
|
||||
export const ContentConfigForm = ({ variableOptions }) => {
|
||||
const { t } = useNotifyMailTranslation();
|
||||
return (
|
||||
<SchemaComponent
|
||||
scope={{ t }}
|
||||
schema={{
|
||||
type: 'void',
|
||||
properties: {
|
||||
subject: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
title: `{{t("Subject")}}`,
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Variable.TextArea',
|
||||
'x-component-props': {
|
||||
scope: variableOptions,
|
||||
},
|
||||
},
|
||||
contentType: {
|
||||
type: 'string',
|
||||
title: `{{t("Content type")}}`,
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Radio.Group',
|
||||
enum: [
|
||||
{ label: 'HTML', value: 'html' },
|
||||
{ label: `{{t("Plain text")}}`, value: 'text' },
|
||||
],
|
||||
default: 'html',
|
||||
},
|
||||
html: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
title: `{{t("Content")}}`,
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Variable.RawTextArea',
|
||||
'x-component-props': {
|
||||
scope: variableOptions,
|
||||
placeholder: 'Hi,',
|
||||
autoSize: {
|
||||
minRows: 10,
|
||||
},
|
||||
},
|
||||
'x-reactions': [
|
||||
{
|
||||
dependencies: ['contentType'],
|
||||
fulfill: {
|
||||
state: {
|
||||
visible: '{{$deps[0] === "html"}}',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
text: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
title: `{{t("Content")}}`,
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Variable.RawTextArea',
|
||||
'x-component-props': {
|
||||
scope: variableOptions,
|
||||
placeholder: 'Hi,',
|
||||
autoSize: {
|
||||
minRows: 10,
|
||||
},
|
||||
},
|
||||
'x-reactions': [
|
||||
{
|
||||
dependencies: ['contentType'],
|
||||
fulfill: {
|
||||
state: {
|
||||
visible: '{{$deps[0] === "text"}}',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
@ -29,8 +29,10 @@ type Message = {
|
||||
export class MailNotificationChannel extends BaseNotificationChannel {
|
||||
transpoter: Transporter;
|
||||
async send(args): Promise<any> {
|
||||
const { message, channel } = args;
|
||||
const { message, channel, receivers } = args;
|
||||
const { host, port, secure, account, password, from } = channel.options;
|
||||
const userRepo = this.app.db.getRepository('users');
|
||||
|
||||
try {
|
||||
const transpoter: Transporter = nodemailer.createTransport({
|
||||
host,
|
||||
@ -42,27 +44,43 @@ export class MailNotificationChannel extends BaseNotificationChannel {
|
||||
},
|
||||
});
|
||||
const { subject, cc, bcc, to, contentType } = message;
|
||||
const payload = {
|
||||
to: to.map((item) => item?.trim()).filter(Boolean),
|
||||
cc: cc
|
||||
? cc
|
||||
.flat()
|
||||
.map((item) => item?.trim())
|
||||
.filter(Boolean)
|
||||
: undefined,
|
||||
bcc: bcc
|
||||
? bcc
|
||||
.flat()
|
||||
.map((item) => item?.trim())
|
||||
.filter(Boolean)
|
||||
: undefined,
|
||||
subject,
|
||||
from,
|
||||
...(contentType === 'html' ? { html: message.html } : { text: message.text }),
|
||||
};
|
||||
if (receivers?.type === 'userId') {
|
||||
const users = await userRepo.find({
|
||||
filter: {
|
||||
$in: receivers.value,
|
||||
},
|
||||
});
|
||||
const usersEmail = users.map((user) => user.email).filter(Boolean);
|
||||
const payload = {
|
||||
to: usersEmail,
|
||||
from,
|
||||
...(contentType === 'html' ? { html: message.html } : { text: message.text }),
|
||||
};
|
||||
const result = await transpoter.sendMail(payload);
|
||||
return { status: 'success', message };
|
||||
} else {
|
||||
const payload = {
|
||||
to: to.map((item) => item?.trim()).filter(Boolean),
|
||||
cc: cc
|
||||
? cc
|
||||
.flat()
|
||||
.map((item) => item?.trim())
|
||||
.filter(Boolean)
|
||||
: undefined,
|
||||
bcc: bcc
|
||||
? bcc
|
||||
.flat()
|
||||
.map((item) => item?.trim())
|
||||
.filter(Boolean)
|
||||
: undefined,
|
||||
subject,
|
||||
from,
|
||||
...(contentType === 'html' ? { html: message.html } : { text: message.text }),
|
||||
};
|
||||
|
||||
const result = await transpoter.sendMail(payload);
|
||||
return { status: 'success', message };
|
||||
const result = await transpoter.sendMail(payload);
|
||||
return { status: 'success', message };
|
||||
}
|
||||
} catch (error) {
|
||||
throw { status: 'failure', reason: error.message, message };
|
||||
}
|
||||
|
@ -0,0 +1,2 @@
|
||||
/node_modules
|
||||
/src
|
@ -0,0 +1 @@
|
||||
# @nocobase/plugin-notification-in-app-message
|
2
packages/plugins/@nocobase/plugin-notification-in-app-message/client.d.ts
vendored
Normal file
2
packages/plugins/@nocobase/plugin-notification-in-app-message/client.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './dist/client';
|
||||
export { default } from './dist/client';
|
@ -0,0 +1 @@
|
||||
module.exports = require('./dist/client/index.js');
|
@ -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"
|
||||
|
||||
}
|
||||
}
|
2
packages/plugins/@nocobase/plugin-notification-in-app-message/server.d.ts
vendored
Normal file
2
packages/plugins/@nocobase/plugin-notification-in-app-message/server.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './dist/server';
|
||||
export { default } from './dist/server';
|
@ -0,0 +1 @@
|
||||
module.exports = require('./dist/server/index.js');
|
@ -0,0 +1,22 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Icon, PinnedPluginListProvider, SchemaComponentOptions, useApp, useRequest } from '@nocobase/client';
|
||||
import { Inbox } from './components/Inbox';
|
||||
export const MessageManagerProvider = (props: any) => {
|
||||
return (
|
||||
<PinnedPluginListProvider
|
||||
items={{
|
||||
inbox: { order: 301, component: 'Inbox', pin: true, snippet: '*' },
|
||||
}}
|
||||
>
|
||||
<SchemaComponentOptions components={{ Inbox }}>{props.children}</SchemaComponentOptions>
|
||||
</PinnedPluginListProvider>
|
||||
);
|
||||
};
|
249
packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/client.d.ts
vendored
Normal file
249
packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/client.d.ts
vendored
Normal file
@ -0,0 +1,249 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
// CSS modules
|
||||
type CSSModuleClasses = { readonly [key: string]: string };
|
||||
|
||||
declare module '*.module.css' {
|
||||
const classes: CSSModuleClasses;
|
||||
export default classes;
|
||||
}
|
||||
declare module '*.module.scss' {
|
||||
const classes: CSSModuleClasses;
|
||||
export default classes;
|
||||
}
|
||||
declare module '*.module.sass' {
|
||||
const classes: CSSModuleClasses;
|
||||
export default classes;
|
||||
}
|
||||
declare module '*.module.less' {
|
||||
const classes: CSSModuleClasses;
|
||||
export default classes;
|
||||
}
|
||||
declare module '*.module.styl' {
|
||||
const classes: CSSModuleClasses;
|
||||
export default classes;
|
||||
}
|
||||
declare module '*.module.stylus' {
|
||||
const classes: CSSModuleClasses;
|
||||
export default classes;
|
||||
}
|
||||
declare module '*.module.pcss' {
|
||||
const classes: CSSModuleClasses;
|
||||
export default classes;
|
||||
}
|
||||
declare module '*.module.sss' {
|
||||
const classes: CSSModuleClasses;
|
||||
export default classes;
|
||||
}
|
||||
|
||||
// CSS
|
||||
declare module '*.css' { }
|
||||
declare module '*.scss' { }
|
||||
declare module '*.sass' { }
|
||||
declare module '*.less' { }
|
||||
declare module '*.styl' { }
|
||||
declare module '*.stylus' { }
|
||||
declare module '*.pcss' { }
|
||||
declare module '*.sss' { }
|
||||
|
||||
// Built-in asset types
|
||||
// see `src/node/constants.ts`
|
||||
|
||||
// images
|
||||
declare module '*.apng' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.png' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.jpg' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.jpeg' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.jfif' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.pjpeg' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.pjp' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.gif' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.svg' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.ico' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.webp' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.avif' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
// media
|
||||
declare module '*.mp4' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.webm' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.ogg' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.mp3' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.wav' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.flac' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.aac' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.opus' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.mov' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.m4a' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.vtt' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
// fonts
|
||||
declare module '*.woff' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.woff2' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.eot' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.ttf' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.otf' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
// other
|
||||
declare module '*.webmanifest' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.pdf' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.txt' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
// wasm?init
|
||||
declare module '*.wasm?init' {
|
||||
const initWasm: (options?: WebAssembly.Imports) => Promise<WebAssembly.Instance>;
|
||||
export default initWasm;
|
||||
}
|
||||
|
||||
// web worker
|
||||
declare module '*?worker' {
|
||||
const workerConstructor: {
|
||||
new(options?: { name?: string }): Worker;
|
||||
};
|
||||
export default workerConstructor;
|
||||
}
|
||||
|
||||
declare module '*?worker&inline' {
|
||||
const workerConstructor: {
|
||||
new(options?: { name?: string }): Worker;
|
||||
};
|
||||
export default workerConstructor;
|
||||
}
|
||||
|
||||
declare module '*?worker&url' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module '*?sharedworker' {
|
||||
const sharedWorkerConstructor: {
|
||||
new(options?: { name?: string }): SharedWorker;
|
||||
};
|
||||
export default sharedWorkerConstructor;
|
||||
}
|
||||
|
||||
declare module '*?sharedworker&inline' {
|
||||
const sharedWorkerConstructor: {
|
||||
new(options?: { name?: string }): SharedWorker;
|
||||
};
|
||||
export default sharedWorkerConstructor;
|
||||
}
|
||||
|
||||
declare module '*?sharedworker&url' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module '*?raw' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module '*?url' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module '*?inline' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
@ -0,0 +1,71 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { SchemaComponent, css } from '@nocobase/client';
|
||||
import { useLocalTranslation } from '../../locale';
|
||||
import { tval } from '@nocobase/utils/client';
|
||||
|
||||
export const ContentConfigForm = ({ variableOptions }) => {
|
||||
const { t } = useLocalTranslation();
|
||||
return (
|
||||
<SchemaComponent
|
||||
scope={{ t }}
|
||||
schema={{
|
||||
type: 'void',
|
||||
properties: {
|
||||
title: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
title: `{{t("Message title")}}`,
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Variable.TextArea',
|
||||
'x-component-props': {
|
||||
scope: variableOptions,
|
||||
useTypedConstant: ['string'],
|
||||
},
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
title: `{{t("Message content")}}`,
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Variable.RawTextArea',
|
||||
'x-component-props': {
|
||||
scope: variableOptions,
|
||||
placeholder: 'Hi,',
|
||||
autoSize: {
|
||||
minRows: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
options: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
title: `{{t("Detail URL")}}`,
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Variable.TextArea',
|
||||
'x-component-props': {
|
||||
scope: variableOptions,
|
||||
useTypedConstant: ['string'],
|
||||
},
|
||||
description: tval(
|
||||
"Support two types of links in nocobase: internal links and external links. If using an internal link, the link starts with '/', for example, '/admin/page'. If using an external link, the link starts with 'http', for example, 'https://example.com'.",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
@ -0,0 +1,96 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please rwefer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useCallback, useContext } from 'react';
|
||||
import { Badge, Button, ConfigProvider, Drawer, Tooltip } from 'antd';
|
||||
import { CloseOutlined } from '@ant-design/icons';
|
||||
import { createStyles } from 'antd-style';
|
||||
import { Icon } from '@nocobase/client';
|
||||
import { InboxContent } from './InboxContent';
|
||||
import { useLocalTranslation } from '../../locale';
|
||||
import { fetchChannels } from '../observables';
|
||||
import { observer } from '@formily/reactive-react';
|
||||
import { useCurrentUserContext } from '@nocobase/client';
|
||||
import {
|
||||
updateUnreadMsgsCount,
|
||||
unreadMsgsCountObs,
|
||||
startMsgSSEStreamWithRetry,
|
||||
inboxVisible,
|
||||
userIdObs,
|
||||
} from '../observables';
|
||||
const useStyles = createStyles(({ token }) => {
|
||||
return {
|
||||
button: {
|
||||
// @ts-ignore
|
||||
color: token.colorTextHeaderMenu + ' !important',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const InnerInbox = (props) => {
|
||||
const { t } = useLocalTranslation();
|
||||
const { styles } = useStyles();
|
||||
const ctx = useCurrentUserContext();
|
||||
const currUserId = ctx.data?.data?.id;
|
||||
|
||||
useEffect(() => {
|
||||
updateUnreadMsgsCount();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
userIdObs.value = currUserId ?? null;
|
||||
}, [currUserId]);
|
||||
const onIconClick = useCallback(() => {
|
||||
inboxVisible.value = true;
|
||||
fetchChannels({});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
startMsgSSEStreamWithRetry();
|
||||
}, []);
|
||||
const DrawerTitle = <div style={{ padding: '0' }}>{t('Message')}</div>;
|
||||
const CloseIcon = (
|
||||
<div style={{ marginLeft: '15px' }}>
|
||||
<CloseOutlined />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
components: { Drawer: { paddingLG: 0 } },
|
||||
}}
|
||||
>
|
||||
<Tooltip title={t('Message')}>
|
||||
<Button className={styles.button} title={'Apps'} icon={<Icon type={'MailOutlined'} />} onClick={onIconClick} />
|
||||
</Tooltip>
|
||||
{unreadMsgsCountObs.value && <Badge count={unreadMsgsCountObs.value} size="small" offset={[-18, -16]}></Badge>}
|
||||
<Drawer
|
||||
title={DrawerTitle}
|
||||
open={inboxVisible.value}
|
||||
closeIcon={CloseIcon}
|
||||
width={900}
|
||||
onClose={() => {
|
||||
inboxVisible.value = false;
|
||||
}}
|
||||
>
|
||||
<InboxContent />
|
||||
</Drawer>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
export const Inbox = observer(InnerInbox);
|
@ -0,0 +1,190 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { observer } from '@formily/reactive-react';
|
||||
|
||||
import { Layout, List, Badge, Button, Flex, Tabs, ConfigProvider, theme } from 'antd';
|
||||
import { css } from '@emotion/css';
|
||||
import { dayjs } from '@nocobase/utils/client';
|
||||
import { useLocalTranslation } from '../../locale';
|
||||
|
||||
import {
|
||||
fetchChannels,
|
||||
selectedChannelNameObs,
|
||||
channelListObs,
|
||||
isFetchingChannelsObs,
|
||||
showChannelLoadingMoreObs,
|
||||
selectedMessageListObs,
|
||||
channelStatusFilterObs,
|
||||
ChannelStatus,
|
||||
} from '../observables';
|
||||
|
||||
import { MessageList } from './MessageList';
|
||||
|
||||
const InnerInboxContent = () => {
|
||||
const { token } = theme.useToken();
|
||||
const { t } = useLocalTranslation();
|
||||
const channels = channelListObs.value;
|
||||
const messages = selectedMessageListObs.value;
|
||||
const selectedChannelName = selectedChannelNameObs.value;
|
||||
|
||||
const onLoadChannelsMore = () => {
|
||||
const filter: Record<string, any> = {};
|
||||
const lastChannel = channels[channels.length - 1];
|
||||
if (lastChannel?.latestMsgReceiveTimestamp) {
|
||||
filter.latestMsgReceiveTimestamp = {
|
||||
$lt: lastChannel.latestMsgReceiveTimestamp,
|
||||
};
|
||||
}
|
||||
fetchChannels({ filter, limit: 30 });
|
||||
};
|
||||
|
||||
const loadChannelsMore = showChannelLoadingMoreObs.value ? (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
marginTop: 12,
|
||||
height: 32,
|
||||
lineHeight: '32px',
|
||||
}}
|
||||
>
|
||||
<Button loading={isFetchingChannelsObs.value} onClick={onLoadChannelsMore}>
|
||||
{t('Loading more')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const FilterTab = () => {
|
||||
interface TabItem {
|
||||
label: string;
|
||||
key: ChannelStatus;
|
||||
}
|
||||
const items: Array<TabItem> = [
|
||||
{ label: t('All'), key: 'all' },
|
||||
{ label: t('Unread'), key: 'unread' },
|
||||
{ label: t('Read'), key: 'read' },
|
||||
];
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
components: { Tabs: { horizontalItemMargin: '20px' } },
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
activeKey={channelStatusFilterObs.value}
|
||||
items={items}
|
||||
onChange={(key: ChannelStatus) => {
|
||||
channelStatusFilterObs.value = key;
|
||||
fetchChannels({});
|
||||
}}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout style={{ height: '100%' }}>
|
||||
<Layout.Sider
|
||||
width={350}
|
||||
style={{
|
||||
height: '100%',
|
||||
overflowY: 'auto',
|
||||
background: token.colorBgContainer,
|
||||
padding: '0 15px',
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
<FilterTab />
|
||||
<List
|
||||
itemLayout="horizontal"
|
||||
dataSource={channels}
|
||||
loadMore={loadChannelsMore}
|
||||
style={{ paddingBottom: '20px' }}
|
||||
loading={channels.length === 0 && isFetchingChannelsObs.value}
|
||||
renderItem={(item) => {
|
||||
const titleColor = selectedChannelName === item.name ? token.colorPrimaryText : token.colorText;
|
||||
const textColor = selectedChannelName === item.name ? token.colorPrimaryText : token.colorTextTertiary;
|
||||
return (
|
||||
<List.Item
|
||||
className={css`
|
||||
&:hover {
|
||||
background-color: ${token.colorBgTextHover}};
|
||||
}
|
||||
`}
|
||||
style={{
|
||||
padding: '10px 10px',
|
||||
color: titleColor,
|
||||
...(selectedChannelName === item.name ? { backgroundColor: token.colorPrimaryBg } : {}),
|
||||
cursor: 'pointer',
|
||||
marginTop: '10px',
|
||||
border: 'none',
|
||||
borderRadius: '10px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
onClick={() => {
|
||||
selectedChannelNameObs.value = item.name;
|
||||
}}
|
||||
>
|
||||
<Flex justify="space-between" style={{ width: '100%' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '150px',
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
{item.title}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
width: '120px',
|
||||
fontWeight: 400,
|
||||
textAlign: 'right',
|
||||
fontFamily: 'monospace',
|
||||
color: textColor,
|
||||
}}
|
||||
>
|
||||
{dayjs(item.latestMsgReceiveTimestamp).fromNow()}
|
||||
</div>
|
||||
</Flex>
|
||||
<Flex justify="space-between" style={{ width: '100%', marginTop: token.margin }}>
|
||||
<div
|
||||
style={{
|
||||
width: '80%',
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
color: textColor,
|
||||
}}
|
||||
>
|
||||
{' '}
|
||||
{item.latestMsgTitle}
|
||||
</div>
|
||||
{channelStatusFilterObs.value !== 'read' ? (
|
||||
<Badge style={{ border: 'none' }} count={item.unreadMsgCnt}></Badge>
|
||||
) : null}
|
||||
</Flex>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Layout.Sider>
|
||||
<Layout.Content style={{ padding: token.paddingLG, height: '100%', overflowY: 'auto' }}>
|
||||
{selectedChannelName ? <MessageList /> : null}
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export const InboxContent = observer(InnerInboxContent);
|
@ -0,0 +1,120 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { SchemaComponent, css } from '@nocobase/client';
|
||||
import { useLocalTranslation } from '../../locale';
|
||||
import { UsersSelect } from './UsersSelect';
|
||||
import { UsersAddition } from './UsersAddition';
|
||||
import { tval } from '@nocobase/utils/client';
|
||||
|
||||
export const MessageConfigForm = ({ variableOptions }) => {
|
||||
const { t } = useLocalTranslation();
|
||||
return (
|
||||
<SchemaComponent
|
||||
scope={{ t }}
|
||||
components={{ UsersSelect, UsersAddition }}
|
||||
schema={{
|
||||
type: 'void',
|
||||
properties: {
|
||||
receivers: {
|
||||
type: 'array',
|
||||
title: `{{t("Receivers")}}`,
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'ArrayItems',
|
||||
items: {
|
||||
type: 'void',
|
||||
'x-component': 'Space',
|
||||
'x-component-props': {
|
||||
className: css`
|
||||
width: 100%;
|
||||
&.ant-space.ant-space-horizontal {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
> .ant-space-item:nth-child(2) {
|
||||
flex-grow: 1;
|
||||
}
|
||||
`,
|
||||
},
|
||||
properties: {
|
||||
sort: {
|
||||
type: 'void',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'ArrayItems.SortHandle',
|
||||
},
|
||||
input: {
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'UsersSelect',
|
||||
},
|
||||
remove: {
|
||||
type: 'void',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'ArrayItems.Remove',
|
||||
},
|
||||
},
|
||||
},
|
||||
required: true,
|
||||
properties: {
|
||||
add: {
|
||||
type: 'void',
|
||||
title: `{{t("Add receiver")}}`,
|
||||
'x-component': 'UsersAddition',
|
||||
},
|
||||
},
|
||||
},
|
||||
title: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
title: `{{t("Message title")}}`,
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Variable.TextArea',
|
||||
'x-component-props': {
|
||||
scope: variableOptions,
|
||||
useTypedConstant: ['string'],
|
||||
},
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
title: `{{t("Message content")}}`,
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Variable.RawTextArea',
|
||||
'x-component-props': {
|
||||
scope: variableOptions,
|
||||
placeholder: 'Hi,',
|
||||
autoSize: {
|
||||
minRows: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
options: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
title: `{{t("Detail URL")}}`,
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Variable.TextArea',
|
||||
'x-component-props': {
|
||||
scope: variableOptions,
|
||||
useTypedConstant: ['string'],
|
||||
},
|
||||
description: tval(
|
||||
"Support two types of links in nocobase: internal links and external links. If using an internal link, the link starts with '/', for example, '/admin/page'. If using an external link, the link starts with 'http', for example, 'https://example.com'.",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
@ -0,0 +1,174 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { observer } from '@formily/reactive-react';
|
||||
|
||||
import { Card, Descriptions, Button, Spin, Tag, ConfigProvider, Typography, Tooltip, theme } from 'antd';
|
||||
import { dayjs } from '@nocobase/utils/client';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLocalTranslation } from '../../locale';
|
||||
|
||||
import {
|
||||
selectedChannelNameObs,
|
||||
channelMapObs,
|
||||
fetchMessages,
|
||||
isFecthingMessageObs,
|
||||
selectedMessageListObs,
|
||||
showMsgLoadingMoreObs,
|
||||
updateMessage,
|
||||
inboxVisible,
|
||||
} from '../observables';
|
||||
|
||||
export const MessageList = observer(() => {
|
||||
const { t } = useLocalTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { token } = theme.useToken();
|
||||
const [hoveredMessageId, setHoveredMessageId] = useState<string | null>(null);
|
||||
const selectedChannelName = selectedChannelNameObs.value;
|
||||
const isFetchingMessages = isFecthingMessageObs.value;
|
||||
const messages = selectedMessageListObs.value;
|
||||
const msgStatusDict = {
|
||||
read: t('Read'),
|
||||
unread: t('Unread'),
|
||||
};
|
||||
if (!selectedChannelName) return null;
|
||||
const onItemClicked = (message) => {
|
||||
updateMessage({
|
||||
filterByTk: message.id,
|
||||
values: {
|
||||
status: 'read',
|
||||
},
|
||||
});
|
||||
if (message.options?.url) {
|
||||
inboxVisible.value = false;
|
||||
const url = message.options.url;
|
||||
if (url.startsWith('/')) navigate(url);
|
||||
else {
|
||||
window.location.href = url;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onLoadMessagesMore = useCallback(() => {
|
||||
const filter: Record<string, any> = {};
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
if (lastMessage) {
|
||||
filter.receiveTimestamp = {
|
||||
$lt: lastMessage.receiveTimestamp,
|
||||
};
|
||||
}
|
||||
if (selectedChannelName) {
|
||||
filter.channelName = selectedChannelName;
|
||||
}
|
||||
fetchMessages({ filter, limit: 30 });
|
||||
}, [messages, selectedChannelName]);
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
components: { Badge: { dotSize: 8 } },
|
||||
}}
|
||||
>
|
||||
<Typography.Title level={4} style={{ marginBottom: token.marginLG }}>
|
||||
{channelMapObs.value[selectedChannelName].title}
|
||||
</Typography.Title>
|
||||
|
||||
{messages.length === 0 && isFecthingMessageObs.value ? (
|
||||
<Spin style={{ width: '100%', marginTop: token.marginXXL }} />
|
||||
) : (
|
||||
messages.map((message, index) => (
|
||||
<>
|
||||
<Card
|
||||
size={'small'}
|
||||
bordered={false}
|
||||
style={{ marginBottom: token.marginMD }}
|
||||
onMouseEnter={() => {
|
||||
setHoveredMessageId(message.id);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setHoveredMessageId(null);
|
||||
}}
|
||||
title={
|
||||
<Tooltip title={message.title} mouseEnterDelay={0.5}>
|
||||
<div
|
||||
onClick={() => {
|
||||
onItemClicked(message);
|
||||
}}
|
||||
style={{
|
||||
fontWeight: message.status === 'unread' ? 'bold' : 'normal',
|
||||
cursor: 'pointer',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{message.title}
|
||||
</div>
|
||||
</Tooltip>
|
||||
}
|
||||
extra={
|
||||
message.options?.url ? (
|
||||
<Button
|
||||
type="link"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onItemClicked(message);
|
||||
}}
|
||||
>
|
||||
{t('View')}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
key={message.id}
|
||||
>
|
||||
<Descriptions key={index} column={1}>
|
||||
<Descriptions.Item label={t('Content')}>
|
||||
{' '}
|
||||
<Tooltip title={message.content.length > 100 ? message.content : ''} mouseEnterDelay={0.5}>
|
||||
{message.content.slice(0, 100) + (message.content.length > 100 ? '...' : '')}{' '}
|
||||
</Tooltip>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('Datetime')}>{dayjs(message.receiveTimestamp).fromNow()}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('Status')}>
|
||||
<div style={{ height: token.controlHeight }}>
|
||||
{hoveredMessageId === message.id && message.status === 'unread' ? (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
style={{ fontSize: token.fontSizeSM }}
|
||||
onClick={() => {
|
||||
updateMessage({
|
||||
filterByTk: message.id,
|
||||
values: {
|
||||
status: 'read',
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
标为已读
|
||||
</Button>
|
||||
) : (
|
||||
<Tag color={message.status === 'unread' ? 'red' : 'green'}>{msgStatusDict[message.status]}</Tag>
|
||||
)}
|
||||
</div>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</>
|
||||
))
|
||||
)}
|
||||
{showMsgLoadingMoreObs.value && (
|
||||
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
|
||||
<Button onClick={onLoadMessagesMore} loading={isFetchingMessages}>
|
||||
{t('Loading more')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</ConfigProvider>
|
||||
);
|
||||
});
|
@ -0,0 +1,67 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { useField } from '@formily/react';
|
||||
import { ArrayField as ArrayFieldModel } from '@formily/core';
|
||||
import { Button, Popover, Radio, Space, Spin, Tag, Tooltip, Typography } from 'antd';
|
||||
import { PlusOutlined, QuestionCircleOutlined } from '@ant-design/icons';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useWorkflowExecuted } from '@nocobase/plugin-workflow/client';
|
||||
import { useLocalTranslation } from '../../locale';
|
||||
|
||||
export function UsersAddition() {
|
||||
const disabled = useWorkflowExecuted();
|
||||
/*
|
||||
waiting for improvement
|
||||
const array = ArrayItems.useArray();
|
||||
*/
|
||||
const [open, setOpen] = useState(false);
|
||||
const { t } = useLocalTranslation();
|
||||
const field = useField<ArrayFieldModel>();
|
||||
/*
|
||||
waiting for improvement
|
||||
const array = ArrayItems.useArray();
|
||||
*/
|
||||
const { receivers } = field.form.values;
|
||||
const onAddSelect = useCallback(() => {
|
||||
receivers.push('');
|
||||
setOpen(false);
|
||||
}, [receivers]);
|
||||
const onAddQuery = useCallback(() => {
|
||||
receivers.push({ filter: {} });
|
||||
setOpen(false);
|
||||
}, [receivers]);
|
||||
|
||||
const button = (
|
||||
<Button icon={<PlusOutlined />} type="dashed" block disabled={disabled} className="ant-formily-array-base-addition">
|
||||
{t('Add user')}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return disabled ? (
|
||||
button
|
||||
) : (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
content={
|
||||
<Space direction="vertical" size="small">
|
||||
<Button type="text" onClick={onAddSelect}>
|
||||
{t('Select users')}
|
||||
</Button>
|
||||
<Button type="text" onClick={onAddQuery}>
|
||||
{t('Query users')}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{button}
|
||||
</Popover>
|
||||
);
|
||||
}
|
@ -0,0 +1,88 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This program is offered under a commercial license.
|
||||
* For more information, see <https://www.nocobase.com/agreement>
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { RemoteSelect, SchemaComponent, Variable, useCollectionFilterOptions, useToken } from '@nocobase/client';
|
||||
import { FilterDynamicComponent, useWorkflowVariableOptions } from '@nocobase/plugin-workflow/client';
|
||||
import { useField } from '@formily/react';
|
||||
|
||||
function isUserKeyField(field) {
|
||||
if (field.isForeignKey) {
|
||||
return field.target === 'users';
|
||||
}
|
||||
return field.collectionName === 'users' && field.name === 'id';
|
||||
}
|
||||
|
||||
export function UsersSelect(props) {
|
||||
const valueType = typeof props.value;
|
||||
|
||||
return valueType === 'object' && props.value ? <UsersQuery {...props} /> : <InternalUsersSelect {...props} />;
|
||||
}
|
||||
|
||||
function InternalUsersSelect({ value, onChange }) {
|
||||
const scope = useWorkflowVariableOptions({ types: [isUserKeyField] });
|
||||
|
||||
return (
|
||||
<Variable.Input scope={scope} value={value} onChange={onChange}>
|
||||
<RemoteSelect
|
||||
fieldNames={{
|
||||
label: 'nickname',
|
||||
value: 'id',
|
||||
}}
|
||||
service={{
|
||||
resource: 'users',
|
||||
}}
|
||||
manual={false}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</Variable.Input>
|
||||
);
|
||||
}
|
||||
|
||||
function UsersQuery(props) {
|
||||
const field = useField<any>();
|
||||
const options = useCollectionFilterOptions('users');
|
||||
const { token } = useToken();
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: `1px dashed ${token.colorBorder}`,
|
||||
padding: token.paddingSM,
|
||||
}}
|
||||
>
|
||||
<SchemaComponent
|
||||
basePath={field.address}
|
||||
schema={{
|
||||
type: 'void',
|
||||
properties: {
|
||||
filter: {
|
||||
type: 'object',
|
||||
'x-component': 'Filter',
|
||||
'x-component-props': {
|
||||
options,
|
||||
dynamicComponent: FilterDynamicComponent,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,122 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useAPIClient, useRequest } from '@nocobase/client';
|
||||
import { produce } from 'immer';
|
||||
export type Message = {
|
||||
id: string;
|
||||
title: string;
|
||||
receiveTimestamp: number;
|
||||
content: string;
|
||||
status: 'read' | 'unread';
|
||||
};
|
||||
export type Group = {
|
||||
id: string;
|
||||
title: string;
|
||||
msgMap: Record<string, Message>;
|
||||
unreadMsgCnt: number;
|
||||
latestMsgReceiveTimestamp: number;
|
||||
latestMsgTitle: string;
|
||||
};
|
||||
const useChats = () => {
|
||||
const apiClient = useAPIClient();
|
||||
const [groupMap, setGroupMap] = useState<Record<string, Group>>({});
|
||||
const addChat = useCallback((chat) => {
|
||||
setGroupMap(
|
||||
produce((draft) => {
|
||||
draft[chat.id] = chat;
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
const addChats = useCallback((groups) => {
|
||||
setGroupMap(
|
||||
produce((draft) => {
|
||||
groups.forEach((group) => {
|
||||
draft[group.id] = { ...draft[group.id], ...group };
|
||||
if (!draft[group.id].msgMap) draft[group.id].msgMap = {};
|
||||
});
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
const requestChats = useCallback(
|
||||
async ({ filter = {}, limit = 30 }: { filter?: Record<string, any>; limit?: number }) => {
|
||||
const res = await apiClient.request({
|
||||
url: 'myInAppChannels:list',
|
||||
method: 'get',
|
||||
params: { filter, limit },
|
||||
});
|
||||
const chats = res.data.data.chats;
|
||||
if (Array.isArray(chats)) return chats;
|
||||
else return [];
|
||||
},
|
||||
[apiClient],
|
||||
);
|
||||
|
||||
const addMessagesToGroup = useCallback(
|
||||
async (groupId: string, messages: Message[]) => {
|
||||
const groups = await requestChats({ filter: { id: groupId } });
|
||||
if (groups.length < 1) return;
|
||||
const group = groups[0];
|
||||
if (group)
|
||||
setGroupMap(
|
||||
produce((draft) => {
|
||||
draft[groupId] = { ...(draft[groupId] ?? {}), ...group };
|
||||
if (!draft[groupId].msgMap) draft[groupId].msgMap = {};
|
||||
messages.forEach((message) => {
|
||||
draft[groupId].msgMap[message.id] = message;
|
||||
});
|
||||
}),
|
||||
);
|
||||
},
|
||||
[requestChats],
|
||||
);
|
||||
|
||||
const chatList = useMemo(() => {
|
||||
return Object.values(groupMap).sort((a, b) => (a.latestMsgReceiveTimestamp > b.latestMsgReceiveTimestamp ? -1 : 1));
|
||||
}, [groupMap]);
|
||||
|
||||
const fetchChats = useCallback(
|
||||
async ({ filter = {}, limit = 30 }: { filter?: Record<string, any>; limit?: number }) => {
|
||||
const res = await apiClient.request({
|
||||
url: 'myInAppChannels:list',
|
||||
method: 'get',
|
||||
params: { filter, limit },
|
||||
});
|
||||
const chats = res.data.data.chats;
|
||||
if (Array.isArray(chats)) addChats(chats);
|
||||
},
|
||||
[apiClient, addChats],
|
||||
);
|
||||
|
||||
const fetchMessages = useCallback(
|
||||
async ({ filter }) => {
|
||||
const res = await apiClient.request({
|
||||
url: 'myInAppMessages:list',
|
||||
method: 'get',
|
||||
params: {
|
||||
filter,
|
||||
},
|
||||
});
|
||||
addMessagesToGroup(filter.channelName, res.data.data.messages);
|
||||
},
|
||||
[apiClient, addMessagesToGroup],
|
||||
);
|
||||
return {
|
||||
chatMap: groupMap,
|
||||
chatList,
|
||||
addChat,
|
||||
addChats,
|
||||
fetchChats,
|
||||
fetchMessages,
|
||||
addMessagesToGroup,
|
||||
};
|
||||
};
|
||||
|
||||
export default useChats;
|
@ -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;
|
@ -0,0 +1,84 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { observable, autorun, reaction } from '@formily/reactive';
|
||||
import { Channel } from '../../types';
|
||||
import { getAPIClient } from '../utils';
|
||||
import { merge } from '@nocobase/utils/client';
|
||||
import { userIdObs } from './user';
|
||||
|
||||
export type ChannelStatus = 'all' | 'read' | 'unread';
|
||||
export enum InappChannelStatusEnum {
|
||||
all = 'all',
|
||||
read = 'read',
|
||||
unread = 'unread',
|
||||
}
|
||||
export const channelMapObs = observable<{ value: Record<string, Channel> }>({ value: {} });
|
||||
export const isFetchingChannelsObs = observable<{ value: boolean }>({ value: false });
|
||||
export const channelCountObs = observable<{ value: number }>({ value: 0 });
|
||||
export const channelStatusFilterObs = observable<{ value: ChannelStatus }>({ value: 'all' });
|
||||
export const channelListObs = observable.computed(() => {
|
||||
const channels = Object.values(channelMapObs.value)
|
||||
.filter((channel) => channel.userId == String(userIdObs.value ?? ''))
|
||||
.filter((channel) => {
|
||||
if (channelStatusFilterObs.value === 'read') return channel.totalMsgCnt - channel.unreadMsgCnt > 0;
|
||||
else if (channelStatusFilterObs.value === 'unread') return channel.unreadMsgCnt > 0;
|
||||
else return true;
|
||||
})
|
||||
.sort((a, b) => (a.latestMsgReceiveTimestamp > b.latestMsgReceiveTimestamp ? -1 : 1));
|
||||
return channels;
|
||||
}) as { value: Channel[] };
|
||||
|
||||
export const showChannelLoadingMoreObs = observable.computed(() => {
|
||||
if (channelListObs.value.length < channelCountObs.value) return true;
|
||||
else return false;
|
||||
}) as { value: boolean };
|
||||
export const selectedChannelNameObs = observable<{ value: string | null }>({ value: null });
|
||||
|
||||
export const fetchChannels = async (params: any) => {
|
||||
const apiClient = getAPIClient();
|
||||
isFetchingChannelsObs.value = true;
|
||||
const res = await apiClient.request({
|
||||
url: 'myInAppChannels:list',
|
||||
method: 'get',
|
||||
params: merge({ filter: { status: channelStatusFilterObs.value } }, params ?? {}),
|
||||
});
|
||||
const channels = res.data?.data;
|
||||
if (Array.isArray(channels)) {
|
||||
channels.forEach((channel: Channel) => {
|
||||
channelMapObs.value[channel.name] = channel;
|
||||
});
|
||||
}
|
||||
const count = res.data?.meta?.count;
|
||||
if (count >= 0) channelCountObs.value = count;
|
||||
isFetchingChannelsObs.value = false;
|
||||
};
|
||||
|
||||
autorun(() => {
|
||||
if (!selectedChannelNameObs.value && channelListObs.value[0]?.name) {
|
||||
selectedChannelNameObs.value = channelListObs.value[0].name;
|
||||
} else if (channelListObs.value.length === 0) {
|
||||
selectedChannelNameObs.value = null;
|
||||
} else if (
|
||||
channelListObs.value.length > 0 &&
|
||||
!channelListObs.value.find((channel) => channel.name === selectedChannelNameObs.value)
|
||||
) {
|
||||
selectedChannelNameObs.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
reaction(
|
||||
() => channelStatusFilterObs.value,
|
||||
() => {
|
||||
if (channelListObs.value[0]?.name) {
|
||||
selectedChannelNameObs.value = channelListObs.value[0].name;
|
||||
}
|
||||
},
|
||||
{ fireImmediately: true },
|
||||
);
|
@ -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 });
|
@ -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';
|
@ -0,0 +1,114 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { observable, autorun } from '@formily/reactive';
|
||||
import { Message } from '../../types';
|
||||
import { getAPIClient } from '../utils';
|
||||
import {
|
||||
channelMapObs,
|
||||
selectedChannelNameObs,
|
||||
fetchChannels,
|
||||
InappChannelStatusEnum,
|
||||
channelStatusFilterObs,
|
||||
} from './channel';
|
||||
import { userIdObs } from './user';
|
||||
import { InAppMessagesDefinition } from '../../types';
|
||||
import { merge } from '@nocobase/utils/client';
|
||||
|
||||
export const messageMapObs = observable<{ value: Record<string, Message> }>({ value: {} });
|
||||
export const isFecthingMessageObs = observable<{ value: boolean }>({ value: false });
|
||||
export const messageListObs = observable.computed(() => {
|
||||
return Object.values(messageMapObs.value).sort((a, b) => (a.receiveTimestamp > b.receiveTimestamp ? -1 : 1));
|
||||
}) as { value: Message[] };
|
||||
|
||||
const filterMessageByStatus = (message: Message) => {
|
||||
if (channelStatusFilterObs.value === 'read') return message.status === 'read';
|
||||
else if (channelStatusFilterObs.value === 'unread') return message.status === 'unread';
|
||||
else return true;
|
||||
};
|
||||
const filterMessageByUserId = (message: Message) => {
|
||||
return message.userId == String(userIdObs.value ?? '');
|
||||
};
|
||||
export const selectedMessageListObs = observable.computed(() => {
|
||||
if (selectedChannelNameObs.value) {
|
||||
const filteredMessages = messageListObs.value.filter(
|
||||
(message) =>
|
||||
message.channelName === selectedChannelNameObs.value && filterMessageByStatus(message) && filterMessageByUserId,
|
||||
);
|
||||
return filteredMessages;
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}) as { value: Message[] };
|
||||
|
||||
export const fetchMessages = async (params: any = { limit: 30 }) => {
|
||||
isFecthingMessageObs.value = true;
|
||||
if (channelStatusFilterObs.value !== 'all')
|
||||
params.filter = merge(params.filter ?? {}, { status: channelStatusFilterObs.value });
|
||||
const apiClient = getAPIClient();
|
||||
const res = await apiClient.request({
|
||||
url: 'myInAppMessages:list',
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
const messages = res?.data?.data.messages;
|
||||
if (Array.isArray(messages)) {
|
||||
messages.forEach((message: Message) => {
|
||||
messageMapObs.value[message.id] = message;
|
||||
});
|
||||
}
|
||||
isFecthingMessageObs.value = false;
|
||||
};
|
||||
|
||||
export const updateMessage = async (params: { filterByTk: any; values: Record<any, any> }) => {
|
||||
const apiClient = getAPIClient();
|
||||
await apiClient.request({
|
||||
resource: InAppMessagesDefinition.name,
|
||||
action: 'update',
|
||||
method: 'post',
|
||||
params,
|
||||
});
|
||||
const unupdatedMessage = messageMapObs.value[params.filterByTk];
|
||||
messageMapObs.value[params.filterByTk] = { ...unupdatedMessage, ...params.values };
|
||||
// fetchChannels({ filter: { name: unupdatedMessage.channelName, status: InappChannelStatusEnum.all } });
|
||||
updateUnreadMsgsCount();
|
||||
};
|
||||
|
||||
autorun(() => {
|
||||
if (selectedChannelNameObs.value) {
|
||||
fetchMessages({ filter: { channelName: selectedChannelNameObs.value } });
|
||||
}
|
||||
});
|
||||
|
||||
export const unreadMsgsCountObs = observable<{ value: number | null }>({ value: null });
|
||||
export const updateUnreadMsgsCount = async () => {
|
||||
const apiClient = getAPIClient();
|
||||
const res = await apiClient.request({
|
||||
url: 'myInAppMessages:count',
|
||||
method: 'get',
|
||||
params: { filter: { status: 'unread' } },
|
||||
});
|
||||
unreadMsgsCountObs.value = res?.data?.data.count;
|
||||
};
|
||||
|
||||
export const showMsgLoadingMoreObs = observable.computed(() => {
|
||||
const selectedChannelId = selectedChannelNameObs.value;
|
||||
if (!selectedChannelId) return false;
|
||||
const selectedChannel = channelMapObs.value[selectedChannelId];
|
||||
const selectedMessageList = selectedMessageListObs.value;
|
||||
|
||||
const isMoreMessageByStatus = {
|
||||
read: selectedChannel.totalMsgCnt - selectedChannel.unreadMsgCnt > selectedMessageList.length,
|
||||
unread: selectedChannel.unreadMsgCnt > selectedMessageList.length,
|
||||
all: selectedChannel.totalMsgCnt > selectedMessageList.length,
|
||||
};
|
||||
if (isMoreMessageByStatus[channelStatusFilterObs.value] && selectedMessageList.length > 0) {
|
||||
return true;
|
||||
}
|
||||
}) as { value: boolean };
|
@ -0,0 +1,102 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { observable, autorun, reaction } from '@formily/reactive';
|
||||
import { notification } from 'antd';
|
||||
import { SSEData } from '../../types';
|
||||
import { messageMapObs, updateUnreadMsgsCount } from './message';
|
||||
import { channelMapObs, fetchChannels, selectedChannelNameObs } from './channel';
|
||||
import { inboxVisible } from './inbox';
|
||||
import { getAPIClient } from '../utils';
|
||||
import { uid } from '@nocobase/utils/client';
|
||||
|
||||
export const liveSSEObs = observable<{ value: SSEData | null }>({ value: null });
|
||||
|
||||
reaction(
|
||||
() => liveSSEObs.value,
|
||||
(sseData) => {
|
||||
if (!sseData) return;
|
||||
|
||||
if (['message:created', 'message:updated'].includes(sseData.type)) {
|
||||
const { data } = sseData;
|
||||
messageMapObs.value[data.id] = data;
|
||||
if (sseData.type === 'message:created') {
|
||||
notification.info({
|
||||
message: (
|
||||
<div
|
||||
style={{
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{data.title}
|
||||
</div>
|
||||
),
|
||||
description: data.content.slice(0, 100) + (data.content.length > 100 ? '...' : ''),
|
||||
onClick: () => {
|
||||
inboxVisible.value = true;
|
||||
selectedChannelNameObs.value = data.channelName;
|
||||
notification.destroy();
|
||||
},
|
||||
});
|
||||
}
|
||||
fetchChannels({ filter: { name: data.channelName } });
|
||||
updateUnreadMsgsCount();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const startMsgSSEStreamWithRetry = async () => {
|
||||
let retryTimes = 0;
|
||||
const clientId = uid();
|
||||
const createMsgSSEConnection = async (clientId: string) => {
|
||||
const apiClient = getAPIClient();
|
||||
const res = await apiClient.silent().request({
|
||||
url: 'myInAppMessages:sse',
|
||||
method: 'get',
|
||||
headers: {
|
||||
Accept: 'text/event-stream',
|
||||
},
|
||||
params: {
|
||||
id: clientId,
|
||||
},
|
||||
responseType: 'stream',
|
||||
adapter: 'fetch',
|
||||
});
|
||||
const stream = res.data;
|
||||
const reader = stream.pipeThrough(new TextDecoderStream()).getReader();
|
||||
retryTimes = 0;
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
const messages = value.split('\n\n').filter(Boolean);
|
||||
for (const message of messages) {
|
||||
const sseData: SSEData = JSON.parse(message.replace(/^data:\s*/, '').trim());
|
||||
liveSSEObs.value = sseData;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const connectWithRetry = async () => {
|
||||
try {
|
||||
await createMsgSSEConnection(clientId);
|
||||
} catch (error) {
|
||||
console.error('Error during stream:', error.message);
|
||||
const nextDelay = retryTimes < 6 ? 1000 * Math.pow(2, retryTimes) : 60000;
|
||||
retryTimes++;
|
||||
setTimeout(() => {
|
||||
connectWithRetry();
|
||||
}, nextDelay);
|
||||
return { error };
|
||||
}
|
||||
};
|
||||
connectWithRetry();
|
||||
};
|
@ -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 });
|
@ -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;
|
@ -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';
|
@ -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'."
|
||||
}
|
@ -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',
|
||||
});
|
||||
}
|
@ -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'。"
|
||||
}
|
@ -0,0 +1,146 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { Application } from '@nocobase/server';
|
||||
import { SendFnType, BaseNotificationChannel } from '@nocobase/plugin-notification-manager';
|
||||
import { InAppMessageFormValues } from '../types';
|
||||
import { PassThrough } from 'stream';
|
||||
import { InAppMessagesDefinition as MessagesDefinition } from '../types';
|
||||
import { parseUserSelectionConf } from './parseUserSelectionConf';
|
||||
import defineMyInAppMessages from './defineMyInAppMessages';
|
||||
import defineMyInAppChannels from './defineMyInAppChannels';
|
||||
|
||||
type UserID = string;
|
||||
type ClientID = string;
|
||||
export default class InAppNotificationChannel extends BaseNotificationChannel {
|
||||
userClientsMap: Record<UserID, Record<ClientID, PassThrough>>;
|
||||
|
||||
constructor(protected app: Application) {
|
||||
super(app);
|
||||
this.userClientsMap = {};
|
||||
}
|
||||
|
||||
async load() {
|
||||
this.onMessageCreatedOrUpdated();
|
||||
this.defineActions();
|
||||
}
|
||||
onMessageCreatedOrUpdated = async () => {
|
||||
this.app.db.on(`${MessagesDefinition.name}.afterUpdate`, async (model, options) => {
|
||||
const userId = model.userId;
|
||||
this.sendDataToUser(userId, { type: 'message:updated', data: model.dataValues });
|
||||
});
|
||||
this.app.db.on(`${MessagesDefinition.name}.afterCreate`, async (model, options) => {
|
||||
const userId = model.userId;
|
||||
this.sendDataToUser(userId, { type: 'message:created', data: model.dataValues });
|
||||
});
|
||||
};
|
||||
|
||||
addClient = (userId: UserID, clientId: ClientID, stream: PassThrough) => {
|
||||
if (!this.userClientsMap[userId]) {
|
||||
this.userClientsMap[userId] = {};
|
||||
}
|
||||
this.userClientsMap[userId][clientId] = stream;
|
||||
};
|
||||
getClient = (userId: UserID, clientId: ClientID) => {
|
||||
return this.userClientsMap[userId]?.[clientId];
|
||||
};
|
||||
removeClient = (userId: UserID, clientId: ClientID) => {
|
||||
if (this.userClientsMap[userId]) {
|
||||
delete this.userClientsMap[userId][clientId];
|
||||
}
|
||||
};
|
||||
sendDataToUser(userId: UserID, message: { type: string; data: any }) {
|
||||
const clients = this.userClientsMap[userId];
|
||||
if (clients) {
|
||||
for (const clientId in clients) {
|
||||
const stream = clients[clientId];
|
||||
stream.write(
|
||||
`data: ${JSON.stringify({
|
||||
type: message.type,
|
||||
data: {
|
||||
...message.data,
|
||||
title: message.data.title.slice(0, 30),
|
||||
content: message.data.content.slice(0, 105),
|
||||
},
|
||||
})}\n\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
saveMessageToDB = async ({
|
||||
content,
|
||||
status,
|
||||
userId,
|
||||
title,
|
||||
channelName,
|
||||
receiveTimestamp,
|
||||
options = {},
|
||||
}: {
|
||||
content: string;
|
||||
userId: number;
|
||||
title: string;
|
||||
channelName: string;
|
||||
status: 'read' | 'unread';
|
||||
receiveTimestamp?: number;
|
||||
options?: Record<string, any>;
|
||||
}): Promise<any> => {
|
||||
const messagesRepo = this.app.db.getRepository(MessagesDefinition.name);
|
||||
const message = await messagesRepo.create({
|
||||
values: {
|
||||
content,
|
||||
title,
|
||||
channelName,
|
||||
status,
|
||||
userId,
|
||||
receiveTimestamp: receiveTimestamp ?? Date.now(),
|
||||
options,
|
||||
},
|
||||
});
|
||||
return message;
|
||||
};
|
||||
|
||||
send: SendFnType<InAppMessageFormValues> = async (params) => {
|
||||
const { channel, message, receivers } = params;
|
||||
let userIds: number[];
|
||||
const { content, title, options = {} } = message;
|
||||
const userRepo = this.app.db.getRepository('users');
|
||||
if (receivers?.type === 'userId') {
|
||||
userIds = receivers.value;
|
||||
} else {
|
||||
userIds = (await parseUserSelectionConf(message.receivers, userRepo)).map((i) => parseInt(i));
|
||||
}
|
||||
await Promise.all(
|
||||
userIds.map(async (userId) => {
|
||||
await this.saveMessageToDB({
|
||||
title,
|
||||
content,
|
||||
status: 'unread',
|
||||
userId,
|
||||
channelName: channel.name,
|
||||
options,
|
||||
});
|
||||
}),
|
||||
);
|
||||
return { status: 'success', message };
|
||||
};
|
||||
|
||||
defineActions() {
|
||||
defineMyInAppMessages({
|
||||
app: this.app,
|
||||
addClient: this.addClient,
|
||||
removeClient: this.removeClient,
|
||||
getClient: this.getClient,
|
||||
});
|
||||
defineMyInAppChannels({ app: this.app });
|
||||
this.app.acl.allow('myInAppMessages', '*', 'loggedIn');
|
||||
this.app.acl.allow('myInAppChannels', '*', 'loggedIn');
|
||||
this.app.acl.allow('notificationInAppMessages', '*', 'loggedIn');
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
@ -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;
|
||||
};
|
@ -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.
|
||||
*/
|
@ -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();
|
||||
// });
|
||||
});
|
||||
});
|
@ -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;
|
@ -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);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
@ -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 };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
@ -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';
|
@ -0,0 +1,30 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { Repository } from '@nocobase/database';
|
||||
export async function parseUserSelectionConf(
|
||||
userSelectionConfig: Array<Record<any, any> | string>,
|
||||
UserRepo: Repository,
|
||||
) {
|
||||
const SelectionConfigs = userSelectionConfig.flat().filter(Boolean);
|
||||
const users = new Set<string>();
|
||||
for (const item of SelectionConfigs) {
|
||||
if (typeof item === 'object') {
|
||||
const result = await UserRepo.find({
|
||||
...item,
|
||||
fields: ['id'],
|
||||
});
|
||||
result.forEach((item) => users.add(item.id));
|
||||
} else {
|
||||
users.add(item);
|
||||
}
|
||||
}
|
||||
|
||||
return [...users];
|
||||
}
|
@ -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;
|
@ -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;
|
||||
};
|
@ -0,0 +1,69 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
export interface Channel {
|
||||
name: string;
|
||||
title: string;
|
||||
userId: string;
|
||||
unreadMsgCnt: number;
|
||||
totalMsgCnt: number;
|
||||
latestMsgReceiveTimestamp: number;
|
||||
latestMsgTitle: string;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
title: string;
|
||||
userId: string;
|
||||
channelName: string;
|
||||
content: string;
|
||||
receiveTimestamp: number;
|
||||
status: 'read' | 'unread';
|
||||
url: string;
|
||||
options: Record<string, any>;
|
||||
}
|
||||
|
||||
export type SSEData = {
|
||||
type: 'message:created';
|
||||
data: Message;
|
||||
};
|
||||
export interface InAppMessageFormValues {
|
||||
receivers: string[];
|
||||
content: string;
|
||||
senderName: string;
|
||||
senderId: string;
|
||||
url: string;
|
||||
title: string;
|
||||
options: Record<string, any>;
|
||||
}
|
||||
|
||||
export const InAppMessagesDefinition = {
|
||||
name: 'notificationInAppMessages',
|
||||
fieldNameMap: {
|
||||
id: 'id',
|
||||
channelName: 'channelName',
|
||||
userId: 'userId',
|
||||
content: 'content',
|
||||
status: 'status',
|
||||
title: 'title',
|
||||
receiveTimestamp: 'receiveTimestamp',
|
||||
options: 'options',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const ChannelsDefinition = {
|
||||
name: 'notificationInAppChannels',
|
||||
fieldNameMap: {
|
||||
id: 'id',
|
||||
senderId: 'senderId',
|
||||
title: 'title',
|
||||
lastMsgId: 'lastMsgId',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const inAppTypeName = 'in-app-message';
|
@ -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',
|
||||
},
|
||||
],
|
||||
};
|
@ -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';
|
||||
};
|
||||
};
|
@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": ["../../../../tsconfig.json"],
|
||||
"compilerOptions": {
|
||||
"strictNullChecks": true,
|
||||
"allowJs": false
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -31,6 +31,8 @@ import {
|
||||
useEditActionProps,
|
||||
useEditFormProps,
|
||||
useNotificationTypes,
|
||||
useRecordDeleteActionProps,
|
||||
useRecordEditActionProps,
|
||||
} from '../hooks';
|
||||
import { channelsSchema, createFormSchema } from '../schemas';
|
||||
import { ConfigForm } from './ConfigForm';
|
||||
@ -48,7 +50,7 @@ const AddNew = () => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const { NotificationTypeNameProvider, name, setName } = useNotificationTypeNameProvider();
|
||||
const api = useAPIClient();
|
||||
const channelTypes = useChannelTypes();
|
||||
const channelTypes = useChannelTypes().filter((item) => !(item.meta?.creatable === false));
|
||||
const items =
|
||||
channelTypes.length === 0
|
||||
? [
|
||||
@ -140,6 +142,8 @@ export const ChannelManager = () => {
|
||||
useCloseActionProps,
|
||||
useEditFormProps,
|
||||
useCreateFormProps,
|
||||
useRecordDeleteActionProps,
|
||||
useRecordEditActionProps,
|
||||
}}
|
||||
/>
|
||||
</NotificationTypesContext.Provider>
|
||||
|
@ -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();
|
||||
|
@ -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')}}",
|
||||
|
@ -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;
|
||||
};
|
||||
};
|
||||
|
@ -0,0 +1,23 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { withDynamicSchemaProps } from '@nocobase/client';
|
||||
import { observer } from '@formily/react';
|
||||
import { useChannelTypeMap } from '../../../../hooks';
|
||||
export const ContentConfigForm = withDynamicSchemaProps(
|
||||
observer<{ variableOptions: any; channelType: string }>(
|
||||
({ variableOptions, channelType }) => {
|
||||
const channelTypeMap = useChannelTypeMap();
|
||||
const { ContentConfigForm = () => null } = (channelType ? channelTypeMap[channelType] : {}).components || {};
|
||||
return <ContentConfigForm variableOptions={variableOptions} />;
|
||||
},
|
||||
{ displayName: 'ContentConfigForm' },
|
||||
),
|
||||
);
|
@ -7,30 +7,24 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import React, { useState, useContext, useEffect } from 'react';
|
||||
import { ArrayItems } from '@formily/antd-v5';
|
||||
import { SchemaComponent, css } from '@nocobase/client';
|
||||
import { onFieldValueChange } from '@formily/core';
|
||||
import { observer, useField, useForm, useFormEffects } from '@formily/react';
|
||||
|
||||
import { useAPIClient, Variable } from '@nocobase/client';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { SchemaComponent } from '@nocobase/client';
|
||||
import { observer, useField } from '@formily/react';
|
||||
import { useAPIClient } from '@nocobase/client';
|
||||
import { useChannelTypeMap } from '../../../../hooks';
|
||||
import { useNotificationTranslation } from '../../../../locale';
|
||||
import { COLLECTION_NAME } from '../../../../../constant';
|
||||
import { UsersAddition } from '../ReceiverConfigForm/Users/UsersAddition';
|
||||
import { UsersSelect } from '../ReceiverConfigForm/Users/Select';
|
||||
export const MessageConfigForm = observer<{ variableOptions: any }>(
|
||||
({ variableOptions }) => {
|
||||
const field = useField();
|
||||
const form = useForm();
|
||||
const { channelName, receiverType } = field.form.values;
|
||||
const [providerName, setProviderName] = useState(null);
|
||||
const { channelName } = field.form.values;
|
||||
const [channelType, setChannelType] = useState(null);
|
||||
const { t } = useNotificationTranslation();
|
||||
const api = useAPIClient();
|
||||
useEffect(() => {
|
||||
const onChannelChange = async () => {
|
||||
if (!channelName) {
|
||||
setProviderName(null);
|
||||
setChannelType(null);
|
||||
return;
|
||||
}
|
||||
const { data } = await api.request({
|
||||
@ -40,25 +34,13 @@ export const MessageConfigForm = observer<{ variableOptions: any }>(
|
||||
filterByTk: channelName,
|
||||
},
|
||||
});
|
||||
setProviderName(data?.data?.notificationType);
|
||||
setChannelType(data?.data?.notificationType);
|
||||
};
|
||||
onChannelChange();
|
||||
}, [channelName, api]);
|
||||
|
||||
useFormEffects(() => {
|
||||
onFieldValueChange('receiverType', (value) => {
|
||||
field.form.values.receivers = [];
|
||||
});
|
||||
});
|
||||
|
||||
// useEffect(() => {
|
||||
// field.form.values.receivers = [];
|
||||
// }, [field.form.values, receiverType]);
|
||||
const providerMap = useChannelTypeMap();
|
||||
const { MessageConfigForm = () => null } = (providerName ? providerMap[providerName] : {}).components || {};
|
||||
|
||||
const ReceiverInputComponent = receiverType === 'user' ? 'UsersSelect' : 'VariableInput';
|
||||
const ReceiverAddition = receiverType === 'user' ? UsersAddition : ArrayItems.Addition;
|
||||
const channelTypeMap = useChannelTypeMap();
|
||||
const { MessageConfigForm = () => null } = (channelType ? channelTypeMap[channelType] : {}).components || {};
|
||||
const createMessageFormSchema = {
|
||||
type: 'void',
|
||||
properties: {
|
||||
@ -93,13 +75,7 @@ export const MessageConfigForm = observer<{ variableOptions: any }>(
|
||||
},
|
||||
},
|
||||
};
|
||||
return (
|
||||
<SchemaComponent
|
||||
schema={createMessageFormSchema}
|
||||
components={{ MessageConfigForm, ReceiverAddition, UsersSelect, ArrayItems, VariableInput: Variable.Input }}
|
||||
scope={{ t }}
|
||||
/>
|
||||
);
|
||||
return <SchemaComponent schema={createMessageFormSchema} components={{ MessageConfigForm }} scope={{ t }} />;
|
||||
},
|
||||
{ displayName: 'MessageConfigForm' },
|
||||
);
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -13,3 +13,10 @@ export enum COLLECTION_NAME {
|
||||
messages = 'messages',
|
||||
logs = 'notificationSendLogs',
|
||||
}
|
||||
|
||||
export const ChannelsCollectionDefinition = {
|
||||
name: COLLECTION_NAME.channels,
|
||||
fieldNameMap: {
|
||||
name: 'name',
|
||||
},
|
||||
};
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -8,12 +8,13 @@
|
||||
*/
|
||||
|
||||
import { Application } from '@nocobase/server';
|
||||
import { ChannelOptions } from './types';
|
||||
import { ChannelOptions, ReceiversOptions } from './types';
|
||||
|
||||
export abstract class BaseNotificationChannel<Message = any> {
|
||||
constructor(protected app: Application) {}
|
||||
abstract send(params: {
|
||||
channel: ChannelOptions;
|
||||
message: Message;
|
||||
receivers?: ReceiversOptions;
|
||||
}): Promise<{ message: Message; status: 'success' | 'fail'; reason?: string }>;
|
||||
}
|
||||
|
@ -9,5 +9,6 @@
|
||||
|
||||
export { BaseNotificationChannel } from './base-notification-channel';
|
||||
export { default } from './plugin';
|
||||
export { COLLECTION_NAME, ChannelsCollectionDefinition } from '../constant';
|
||||
|
||||
export * from './types';
|
||||
|
@ -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;
|
||||
|
@ -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',
|
||||
|
@ -36,12 +36,24 @@ export type WriteLogOptions = {
|
||||
export type SendFnType<Message> = (args: {
|
||||
message: Message;
|
||||
channel: ChannelOptions;
|
||||
receivers?: ReceiversOptions;
|
||||
}) => Promise<{ message: Message; status: 'success' | 'fail'; reason?: string }>;
|
||||
|
||||
export type ReceiversOptions =
|
||||
| { value: number[]; type: 'userId' }
|
||||
| { value: any; type: 'channel-self-defined'; channelType: string };
|
||||
export interface SendOptions {
|
||||
channelName: string;
|
||||
message: Record<string, any>;
|
||||
triggerFrom: string;
|
||||
receivers?: ReceiversOptions;
|
||||
}
|
||||
|
||||
export interface SendUserOptions {
|
||||
userIds: number[];
|
||||
channels: string[];
|
||||
message: Record<string, any>;
|
||||
data?: Record<string, any>;
|
||||
}
|
||||
|
||||
export type NotificationChannelConstructor = new (app: Application) => BaseNotificationChannel;
|
||||
|
@ -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 = () => {
|
||||
|
@ -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",
|
||||
|
Loading…
Reference in New Issue
Block a user