mirror of
https://gitee.com/nocobase/nocobase.git
synced 2025-05-05 21:49:25 +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.auth = this.auth;
|
||||||
api.storagePrefix = this.storagePrefix;
|
api.storagePrefix = this.storagePrefix;
|
||||||
api.notification = this.notification;
|
api.notification = this.notification;
|
||||||
api.axios = this.axios;
|
|
||||||
return api;
|
return api;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -27,7 +27,7 @@ export const PinnedPluginListProvider: React.FC<{ items: any }> = (props) => {
|
|||||||
export const PinnedPluginList = () => {
|
export const PinnedPluginList = () => {
|
||||||
const { allowAll, snippets } = useACLRoleContext();
|
const { allowAll, snippets } = useACLRoleContext();
|
||||||
const getSnippetsAllow = (aclKey) => {
|
const getSnippetsAllow = (aclKey) => {
|
||||||
return allowAll || snippets?.includes(aclKey);
|
return allowAll || aclKey === '*' || snippets?.includes(aclKey);
|
||||||
};
|
};
|
||||||
const ctx = useContext(PinnedPluginListContext);
|
const ctx = useContext(PinnedPluginListContext);
|
||||||
const { components } = useContext(SchemaOptionsContext);
|
const { components } = useContext(SchemaOptionsContext);
|
||||||
|
@ -10,6 +10,7 @@
|
|||||||
import merge from 'deepmerge';
|
import merge from 'deepmerge';
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
import { default as _, default as lodash } from 'lodash';
|
import { default as _, default as lodash } from 'lodash';
|
||||||
|
import safeJsonStringify from 'safe-json-stringify';
|
||||||
import {
|
import {
|
||||||
ModelOptions,
|
ModelOptions,
|
||||||
ModelStatic,
|
ModelStatic,
|
||||||
@ -25,7 +26,6 @@ import { BelongsToField, Field, FieldOptions, HasManyField } from './fields';
|
|||||||
import { Model } from './model';
|
import { Model } from './model';
|
||||||
import { Repository } from './repository';
|
import { Repository } from './repository';
|
||||||
import { checkIdentifier, md5, snakeCase } from './utils';
|
import { checkIdentifier, md5, snakeCase } from './utils';
|
||||||
import safeJsonStringify from 'safe-json-stringify';
|
|
||||||
|
|
||||||
export type RepositoryType = typeof Repository;
|
export type RepositoryType = typeof Repository;
|
||||||
|
|
||||||
@ -864,6 +864,16 @@ export class Collection<
|
|||||||
return `${schema}.${tableName}`;
|
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() {
|
public getTableNameWithSchemaAsString() {
|
||||||
const tableName = this.model.tableName;
|
const tableName = this.model.tableName;
|
||||||
|
|
||||||
|
@ -26,7 +26,7 @@ import { i18n } from './middlewares/i18n';
|
|||||||
export function createI18n(options: ApplicationOptions) {
|
export function createI18n(options: ApplicationOptions) {
|
||||||
const instance = i18next.createInstance();
|
const instance = i18next.createInstance();
|
||||||
instance.init({
|
instance.init({
|
||||||
lng: 'en-US',
|
lng: process.env.INIT_LANG || 'en-US',
|
||||||
resources: {},
|
resources: {},
|
||||||
keySeparator: false,
|
keySeparator: false,
|
||||||
nsSeparator: 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 { channelType, NAMESPACE } from '../constant';
|
||||||
import { ChannelConfigForm } from './ConfigForm';
|
import { ChannelConfigForm } from './ConfigForm';
|
||||||
import { MessageConfigForm } from './MessageConfigForm';
|
import { MessageConfigForm } from './MessageConfigForm';
|
||||||
|
import { ContentConfigForm } from './ContentConfigForm';
|
||||||
export class PluginNotificationMailClient extends Plugin {
|
export class PluginNotificationMailClient extends Plugin {
|
||||||
async afterAdd() {}
|
async afterAdd() {}
|
||||||
|
|
||||||
@ -25,6 +26,7 @@ export class PluginNotificationMailClient extends Plugin {
|
|||||||
components: {
|
components: {
|
||||||
ChannelConfigForm: ChannelConfigForm,
|
ChannelConfigForm: ChannelConfigForm,
|
||||||
MessageConfigForm: MessageConfigForm,
|
MessageConfigForm: MessageConfigForm,
|
||||||
|
ContentConfigForm,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -29,8 +29,10 @@ type Message = {
|
|||||||
export class MailNotificationChannel extends BaseNotificationChannel {
|
export class MailNotificationChannel extends BaseNotificationChannel {
|
||||||
transpoter: Transporter;
|
transpoter: Transporter;
|
||||||
async send(args): Promise<any> {
|
async send(args): Promise<any> {
|
||||||
const { message, channel } = args;
|
const { message, channel, receivers } = args;
|
||||||
const { host, port, secure, account, password, from } = channel.options;
|
const { host, port, secure, account, password, from } = channel.options;
|
||||||
|
const userRepo = this.app.db.getRepository('users');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const transpoter: Transporter = nodemailer.createTransport({
|
const transpoter: Transporter = nodemailer.createTransport({
|
||||||
host,
|
host,
|
||||||
@ -42,27 +44,43 @@ export class MailNotificationChannel extends BaseNotificationChannel {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const { subject, cc, bcc, to, contentType } = message;
|
const { subject, cc, bcc, to, contentType } = message;
|
||||||
const payload = {
|
if (receivers?.type === 'userId') {
|
||||||
to: to.map((item) => item?.trim()).filter(Boolean),
|
const users = await userRepo.find({
|
||||||
cc: cc
|
filter: {
|
||||||
? cc
|
$in: receivers.value,
|
||||||
.flat()
|
},
|
||||||
.map((item) => item?.trim())
|
});
|
||||||
.filter(Boolean)
|
const usersEmail = users.map((user) => user.email).filter(Boolean);
|
||||||
: undefined,
|
const payload = {
|
||||||
bcc: bcc
|
to: usersEmail,
|
||||||
? bcc
|
from,
|
||||||
.flat()
|
...(contentType === 'html' ? { html: message.html } : { text: message.text }),
|
||||||
.map((item) => item?.trim())
|
};
|
||||||
.filter(Boolean)
|
const result = await transpoter.sendMail(payload);
|
||||||
: undefined,
|
return { status: 'success', message };
|
||||||
subject,
|
} else {
|
||||||
from,
|
const payload = {
|
||||||
...(contentType === 'html' ? { html: message.html } : { text: message.text }),
|
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);
|
const result = await transpoter.sendMail(payload);
|
||||||
return { status: 'success', message };
|
return { status: 'success', message };
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw { status: 'failure', reason: error.message, message };
|
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 { NotificationVariableContext, NotificationVariableProvider, useNotificationVariableOptions } from './hooks';
|
||||||
export { MessageConfigForm } from './manager/message/components/MessageConfigForm';
|
export { MessageConfigForm } from './manager/message/components/MessageConfigForm';
|
||||||
|
export { ContentConfigForm } from './manager/message/components/ContentConfigForm';
|
||||||
export default PluginNotificationManagerClient;
|
export default PluginNotificationManagerClient;
|
||||||
|
@ -31,6 +31,8 @@ import {
|
|||||||
useEditActionProps,
|
useEditActionProps,
|
||||||
useEditFormProps,
|
useEditFormProps,
|
||||||
useNotificationTypes,
|
useNotificationTypes,
|
||||||
|
useRecordDeleteActionProps,
|
||||||
|
useRecordEditActionProps,
|
||||||
} from '../hooks';
|
} from '../hooks';
|
||||||
import { channelsSchema, createFormSchema } from '../schemas';
|
import { channelsSchema, createFormSchema } from '../schemas';
|
||||||
import { ConfigForm } from './ConfigForm';
|
import { ConfigForm } from './ConfigForm';
|
||||||
@ -48,7 +50,7 @@ const AddNew = () => {
|
|||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
const { NotificationTypeNameProvider, name, setName } = useNotificationTypeNameProvider();
|
const { NotificationTypeNameProvider, name, setName } = useNotificationTypeNameProvider();
|
||||||
const api = useAPIClient();
|
const api = useAPIClient();
|
||||||
const channelTypes = useChannelTypes();
|
const channelTypes = useChannelTypes().filter((item) => !(item.meta?.creatable === false));
|
||||||
const items =
|
const items =
|
||||||
channelTypes.length === 0
|
channelTypes.length === 0
|
||||||
? [
|
? [
|
||||||
@ -140,6 +142,8 @@ export const ChannelManager = () => {
|
|||||||
useCloseActionProps,
|
useCloseActionProps,
|
||||||
useEditFormProps,
|
useEditFormProps,
|
||||||
useCreateFormProps,
|
useCreateFormProps,
|
||||||
|
useRecordDeleteActionProps,
|
||||||
|
useRecordEditActionProps,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</NotificationTypesContext.Provider>
|
</NotificationTypesContext.Provider>
|
||||||
|
@ -17,6 +17,7 @@ import {
|
|||||||
useCollection,
|
useCollection,
|
||||||
useCollectionRecordData,
|
useCollectionRecordData,
|
||||||
useDataBlockRequest,
|
useDataBlockRequest,
|
||||||
|
useDestroyActionProps,
|
||||||
useDataBlockResource,
|
useDataBlockResource,
|
||||||
usePlugin,
|
usePlugin,
|
||||||
} from '@nocobase/client';
|
} from '@nocobase/client';
|
||||||
@ -33,7 +34,6 @@ export const useCreateActionProps = () => {
|
|||||||
const form = useForm();
|
const form = useForm();
|
||||||
const resource = useDataBlockResource();
|
const resource = useDataBlockResource();
|
||||||
const { service } = useBlockRequestContext();
|
const { service } = useBlockRequestContext();
|
||||||
const collection = useCollection();
|
|
||||||
return {
|
return {
|
||||||
type: 'primary',
|
type: 'primary',
|
||||||
async onClick(e?, callBack?) {
|
async onClick(e?, callBack?) {
|
||||||
@ -104,6 +104,26 @@ export const useEditFormProps = () => {
|
|||||||
form,
|
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 = () => {
|
export const useCreateFormProps = () => {
|
||||||
const ctx = useActionContext();
|
const ctx = useActionContext();
|
||||||
|
@ -177,6 +177,7 @@ export const channelsSchema: ISchema = {
|
|||||||
openMode: 'drawer',
|
openMode: 'drawer',
|
||||||
icon: 'EditOutlined',
|
icon: 'EditOutlined',
|
||||||
},
|
},
|
||||||
|
'x-use-component-props': 'useRecordEditActionProps',
|
||||||
'x-decorator': 'Space',
|
'x-decorator': 'Space',
|
||||||
properties: {
|
properties: {
|
||||||
drawer: {
|
drawer: {
|
||||||
@ -212,7 +213,7 @@ export const channelsSchema: ISchema = {
|
|||||||
title: '{{t("Delete")}}',
|
title: '{{t("Delete")}}',
|
||||||
'x-decorator': 'Space',
|
'x-decorator': 'Space',
|
||||||
'x-component': 'Action.Link',
|
'x-component': 'Action.Link',
|
||||||
'x-use-component-props': 'useDestroyActionProps',
|
'x-use-component-props': 'useRecordDeleteActionProps',
|
||||||
'x-component-props': {
|
'x-component-props': {
|
||||||
confirm: {
|
confirm: {
|
||||||
title: "{{t('Delete record')}}",
|
title: "{{t('Delete record')}}",
|
||||||
|
@ -16,10 +16,11 @@ export type RegisterChannelOptions = {
|
|||||||
components: {
|
components: {
|
||||||
ChannelConfigForm: ComponentType;
|
ChannelConfigForm: ComponentType;
|
||||||
MessageConfigForm?: ComponentType<{ variableOptions: any }>;
|
MessageConfigForm?: ComponentType<{ variableOptions: any }>;
|
||||||
|
ContentConfigForm?: ComponentType<{ variableOptions?: any }>;
|
||||||
};
|
};
|
||||||
meta?: {
|
meta?: {
|
||||||
creatable?: boolean;
|
creatable?: boolean;
|
||||||
eidtable?: boolean;
|
editable?: boolean;
|
||||||
deletable?: 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.
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useContext, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { ArrayItems } from '@formily/antd-v5';
|
import { SchemaComponent } from '@nocobase/client';
|
||||||
import { SchemaComponent, css } from '@nocobase/client';
|
import { observer, useField } from '@formily/react';
|
||||||
import { onFieldValueChange } from '@formily/core';
|
import { useAPIClient } from '@nocobase/client';
|
||||||
import { observer, useField, useForm, useFormEffects } from '@formily/react';
|
|
||||||
|
|
||||||
import { useAPIClient, Variable } from '@nocobase/client';
|
|
||||||
import { useChannelTypeMap } from '../../../../hooks';
|
import { useChannelTypeMap } from '../../../../hooks';
|
||||||
import { useNotificationTranslation } from '../../../../locale';
|
import { useNotificationTranslation } from '../../../../locale';
|
||||||
import { COLLECTION_NAME } from '../../../../../constant';
|
import { COLLECTION_NAME } from '../../../../../constant';
|
||||||
import { UsersAddition } from '../ReceiverConfigForm/Users/UsersAddition';
|
|
||||||
import { UsersSelect } from '../ReceiverConfigForm/Users/Select';
|
|
||||||
export const MessageConfigForm = observer<{ variableOptions: any }>(
|
export const MessageConfigForm = observer<{ variableOptions: any }>(
|
||||||
({ variableOptions }) => {
|
({ variableOptions }) => {
|
||||||
const field = useField();
|
const field = useField();
|
||||||
const form = useForm();
|
const { channelName } = field.form.values;
|
||||||
const { channelName, receiverType } = field.form.values;
|
const [channelType, setChannelType] = useState(null);
|
||||||
const [providerName, setProviderName] = useState(null);
|
|
||||||
const { t } = useNotificationTranslation();
|
const { t } = useNotificationTranslation();
|
||||||
const api = useAPIClient();
|
const api = useAPIClient();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onChannelChange = async () => {
|
const onChannelChange = async () => {
|
||||||
if (!channelName) {
|
if (!channelName) {
|
||||||
setProviderName(null);
|
setChannelType(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { data } = await api.request({
|
const { data } = await api.request({
|
||||||
@ -40,25 +34,13 @@ export const MessageConfigForm = observer<{ variableOptions: any }>(
|
|||||||
filterByTk: channelName,
|
filterByTk: channelName,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
setProviderName(data?.data?.notificationType);
|
setChannelType(data?.data?.notificationType);
|
||||||
};
|
};
|
||||||
onChannelChange();
|
onChannelChange();
|
||||||
}, [channelName, api]);
|
}, [channelName, api]);
|
||||||
|
|
||||||
useFormEffects(() => {
|
const channelTypeMap = useChannelTypeMap();
|
||||||
onFieldValueChange('receiverType', (value) => {
|
const { MessageConfigForm = () => null } = (channelType ? channelTypeMap[channelType] : {}).components || {};
|
||||||
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 createMessageFormSchema = {
|
const createMessageFormSchema = {
|
||||||
type: 'void',
|
type: 'void',
|
||||||
properties: {
|
properties: {
|
||||||
@ -93,13 +75,7 @@ export const MessageConfigForm = observer<{ variableOptions: any }>(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return (
|
return <SchemaComponent schema={createMessageFormSchema} components={{ MessageConfigForm }} scope={{ t }} />;
|
||||||
<SchemaComponent
|
|
||||||
schema={createMessageFormSchema}
|
|
||||||
components={{ MessageConfigForm, ReceiverAddition, UsersSelect, ArrayItems, VariableInput: Variable.Input }}
|
|
||||||
scope={{ t }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
{ displayName: 'MessageConfigForm' },
|
{ displayName: 'MessageConfigForm' },
|
||||||
);
|
);
|
||||||
|
@ -8,16 +8,20 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { COLLECTION_NAME } from '../constant';
|
import { COLLECTION_NAME } from '../constant';
|
||||||
import { CollectionOptions } from '@nocobase/client';
|
|
||||||
|
|
||||||
const channelCollection: CollectionOptions = {
|
export default {
|
||||||
name: COLLECTION_NAME.channels,
|
name: COLLECTION_NAME.channels,
|
||||||
autoGenId: false,
|
|
||||||
filterTargetKey: 'name',
|
filterTargetKey: 'name',
|
||||||
|
autoGenId: false,
|
||||||
|
createdAt: true,
|
||||||
|
createdBy: true,
|
||||||
|
updatedAt: true,
|
||||||
|
updatedBy: true,
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
name: 'name',
|
name: 'name',
|
||||||
type: 'uid',
|
type: 'uid',
|
||||||
|
prefix: 's_',
|
||||||
primaryKey: true,
|
primaryKey: true,
|
||||||
interface: 'input',
|
interface: 'input',
|
||||||
uiSchema: {
|
uiSchema: {
|
||||||
@ -50,6 +54,11 @@ const channelCollection: CollectionOptions = {
|
|||||||
'x-component': 'ConfigForm',
|
'x-component': 'ConfigForm',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'meta',
|
||||||
|
type: 'json',
|
||||||
|
interface: 'json',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
interface: 'input',
|
interface: 'input',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
@ -72,55 +81,5 @@ const channelCollection: CollectionOptions = {
|
|||||||
title: '{{t("Description")}}',
|
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.
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { CollectionOptions } from '@nocobase/client';
|
|
||||||
import { COLLECTION_NAME } from '../constant';
|
import { COLLECTION_NAME } from '../constant';
|
||||||
|
|
||||||
const collectionOption: CollectionOptions = {
|
export default {
|
||||||
name: COLLECTION_NAME.logs,
|
name: COLLECTION_NAME.logs,
|
||||||
title: 'MessageLogs',
|
title: 'MessageLogs',
|
||||||
fields: [
|
fields: [
|
||||||
@ -132,5 +131,3 @@ const collectionOption: CollectionOptions = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default collectionOption;
|
|
||||||
|
@ -13,3 +13,10 @@ export enum COLLECTION_NAME {
|
|||||||
messages = 'messages',
|
messages = 'messages',
|
||||||
logs = 'notificationSendLogs',
|
logs = 'notificationSendLogs',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const ChannelsCollectionDefinition = {
|
||||||
|
name: COLLECTION_NAME.channels,
|
||||||
|
fieldNameMap: {
|
||||||
|
name: 'name',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
@ -44,7 +44,6 @@ describe('notification manager server', () => {
|
|||||||
test('create channel', async () => {
|
test('create channel', async () => {
|
||||||
class TestNotificationMailServer {
|
class TestNotificationMailServer {
|
||||||
async send({ message, channel }) {
|
async send({ message, channel }) {
|
||||||
console.log('senddddd', message, channel);
|
|
||||||
expect(channel.options.test).toEqual(1);
|
expect(channel.options.test).toEqual(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -8,12 +8,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Application } from '@nocobase/server';
|
import { Application } from '@nocobase/server';
|
||||||
import { ChannelOptions } from './types';
|
import { ChannelOptions, ReceiversOptions } from './types';
|
||||||
|
|
||||||
export abstract class BaseNotificationChannel<Message = any> {
|
export abstract class BaseNotificationChannel<Message = any> {
|
||||||
constructor(protected app: Application) {}
|
constructor(protected app: Application) {}
|
||||||
abstract send(params: {
|
abstract send(params: {
|
||||||
channel: ChannelOptions;
|
channel: ChannelOptions;
|
||||||
message: Message;
|
message: Message;
|
||||||
|
receivers?: ReceiversOptions;
|
||||||
}): Promise<{ message: Message; status: 'success' | 'fail'; reason?: string }>;
|
}): Promise<{ message: Message; status: 'success' | 'fail'; reason?: string }>;
|
||||||
}
|
}
|
||||||
|
@ -9,5 +9,6 @@
|
|||||||
|
|
||||||
export { BaseNotificationChannel } from './base-notification-channel';
|
export { BaseNotificationChannel } from './base-notification-channel';
|
||||||
export { default } from './plugin';
|
export { default } from './plugin';
|
||||||
|
export { COLLECTION_NAME, ChannelsCollectionDefinition } from '../constant';
|
||||||
|
|
||||||
export * from './types';
|
export * from './types';
|
||||||
|
@ -10,7 +10,13 @@
|
|||||||
import { Registry } from '@nocobase/utils';
|
import { Registry } from '@nocobase/utils';
|
||||||
import { COLLECTION_NAME } from '../constant';
|
import { COLLECTION_NAME } from '../constant';
|
||||||
import PluginNotificationManagerServer from './plugin';
|
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 {
|
export class NotificationManager implements NotificationManager {
|
||||||
private plugin: PluginNotificationManagerServer;
|
private plugin: PluginNotificationManagerServer;
|
||||||
@ -29,29 +35,6 @@ export class NotificationManager implements NotificationManager {
|
|||||||
return logsRepo.create({ values: options });
|
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) {
|
async send(params: SendOptions) {
|
||||||
this.plugin.logger.info('receive sending message request', params);
|
this.plugin.logger.info('receive sending message request', params);
|
||||||
const channelsRepo = this.plugin.app.db.getRepository(COLLECTION_NAME.channels);
|
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);
|
const instance = new Channel(this.plugin.app);
|
||||||
logData.channelTitle = channel.title;
|
logData.channelTitle = channel.title;
|
||||||
logData.notificationType = channel.notificationType;
|
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.status = result.status;
|
||||||
logData.reason = result.reason;
|
logData.reason = result.reason;
|
||||||
} else {
|
} else {
|
||||||
@ -83,6 +66,14 @@ export class NotificationManager implements NotificationManager {
|
|||||||
return logData;
|
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;
|
export default NotificationManager;
|
||||||
|
@ -10,7 +10,7 @@
|
|||||||
import type { Logger } from '@nocobase/logger';
|
import type { Logger } from '@nocobase/logger';
|
||||||
import { Plugin } from '@nocobase/server';
|
import { Plugin } from '@nocobase/server';
|
||||||
import NotificationManager from './manager';
|
import NotificationManager from './manager';
|
||||||
import { RegisterServerTypeFnParams, SendOptions } from './types';
|
import { RegisterServerTypeFnParams, SendOptions, SendUserOptions } from './types';
|
||||||
export class PluginNotificationManagerServer extends Plugin {
|
export class PluginNotificationManagerServer extends Plugin {
|
||||||
private manager: NotificationManager;
|
private manager: NotificationManager;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
@ -22,6 +22,10 @@ export class PluginNotificationManagerServer extends Plugin {
|
|||||||
return await this.manager.send(options);
|
return await this.manager.send(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async sendToUsers(options: SendUserOptions) {
|
||||||
|
return await this.manager.sendToUsers(options);
|
||||||
|
}
|
||||||
|
|
||||||
async afterAdd() {
|
async afterAdd() {
|
||||||
this.logger = this.createLogger({
|
this.logger = this.createLogger({
|
||||||
dirname: 'notification-manager',
|
dirname: 'notification-manager',
|
||||||
|
@ -36,12 +36,24 @@ export type WriteLogOptions = {
|
|||||||
export type SendFnType<Message> = (args: {
|
export type SendFnType<Message> = (args: {
|
||||||
message: Message;
|
message: Message;
|
||||||
channel: ChannelOptions;
|
channel: ChannelOptions;
|
||||||
|
receivers?: ReceiversOptions;
|
||||||
}) => Promise<{ message: Message; status: 'success' | 'fail'; reason?: string }>;
|
}) => 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 {
|
export interface SendOptions {
|
||||||
channelName: string;
|
channelName: string;
|
||||||
message: Record<string, any>;
|
message: Record<string, any>;
|
||||||
triggerFrom: string;
|
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;
|
export type NotificationChannelConstructor = new (app: Application) => BaseNotificationChannel;
|
||||||
|
@ -9,6 +9,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Instruction, useWorkflowVariableOptions } from '@nocobase/plugin-workflow/client';
|
import { Instruction, useWorkflowVariableOptions } from '@nocobase/plugin-workflow/client';
|
||||||
import { MessageConfigForm } from '@nocobase/plugin-notification-manager/client';
|
import { MessageConfigForm } from '@nocobase/plugin-notification-manager/client';
|
||||||
|
|
||||||
import { NAMESPACE } from '../locale';
|
import { NAMESPACE } from '../locale';
|
||||||
|
|
||||||
const LocalProvider = () => {
|
const LocalProvider = () => {
|
||||||
|
@ -68,6 +68,7 @@
|
|||||||
"@nocobase/plugin-workflow-sql": "1.4.0-alpha",
|
"@nocobase/plugin-workflow-sql": "1.4.0-alpha",
|
||||||
"@nocobase/plugin-workflow-notification": "1.4.0-alpha",
|
"@nocobase/plugin-workflow-notification": "1.4.0-alpha",
|
||||||
"@nocobase/server": "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-email": "1.4.0-alpha",
|
||||||
"@nocobase/plugin-notification-manager": "1.4.0-alpha",
|
"@nocobase/plugin-notification-manager": "1.4.0-alpha",
|
||||||
"cronstrue": "^2.11.0",
|
"cronstrue": "^2.11.0",
|
||||||
@ -107,6 +108,7 @@
|
|||||||
"@nocobase/plugin-kanban",
|
"@nocobase/plugin-kanban",
|
||||||
"@nocobase/plugin-logger",
|
"@nocobase/plugin-logger",
|
||||||
"@nocobase/plugin-notification-manager",
|
"@nocobase/plugin-notification-manager",
|
||||||
|
"@nocobase/plugin-notification-in-app-message",
|
||||||
"@nocobase/plugin-mobile",
|
"@nocobase/plugin-mobile",
|
||||||
"@nocobase/plugin-system-settings",
|
"@nocobase/plugin-system-settings",
|
||||||
"@nocobase/plugin-ui-schema-storage",
|
"@nocobase/plugin-ui-schema-storage",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user