feat(notification): in-app message support

This commit is contained in:
Sheldon Guo 2025-04-24 15:16:58 +08:00
parent e3caaee61c
commit cd8141ca15
10 changed files with 175 additions and 52 deletions

View File

@ -7,10 +7,10 @@
* For more information, please refer to: https://www.nocobase.com/agreement. * For more information, please refer to: https://www.nocobase.com/agreement.
*/ */
import React from 'react';
import { SchemaComponent, css } from '@nocobase/client'; import { SchemaComponent, css } from '@nocobase/client';
import { useLocalTranslation } from '../../locale';
import { tval } from '@nocobase/utils/client'; import { tval } from '@nocobase/utils/client';
import React from 'react';
import { useLocalTranslation } from '../../locale';
export const ContentConfigForm = ({ variableOptions }) => { export const ContentConfigForm = ({ variableOptions }) => {
const { t } = useLocalTranslation(); const { t } = useLocalTranslation();
@ -31,6 +31,17 @@ export const ContentConfigForm = ({ variableOptions }) => {
useTypedConstant: ['string'], useTypedConstant: ['string'],
}, },
}, },
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',
},
content: { content: {
type: 'string', type: 'string',
required: true, required: true,

View File

@ -16,27 +16,26 @@
* For more information, please rwefer to: https://www.nocobase.com/agreement. * For more information, please rwefer to: https://www.nocobase.com/agreement.
*/ */
import React, { useEffect, useCallback } from 'react';
import { reaction } from '@formily/reactive';
import { Badge, Button, ConfigProvider, Drawer, Tooltip, notification } from 'antd';
import { CloseOutlined } from '@ant-design/icons'; import { CloseOutlined } from '@ant-design/icons';
import { createStyles } from 'antd-style'; import { reaction } from '@formily/reactive';
import { Icon } from '@nocobase/client';
import { InboxContent } from './InboxContent';
import { useLocalTranslation } from '../../locale';
import { fetchChannels } from '../observables';
import { observer } from '@formily/reactive-react'; import { observer } from '@formily/reactive-react';
import { useCurrentUserContext } from '@nocobase/client'; import { Icon, useCurrentUserContext } from '@nocobase/client';
import { Badge, Button, ConfigProvider, Drawer, Tooltip, notification } from 'antd';
import { createStyles } from 'antd-style';
import React, { useCallback, useEffect } from 'react';
import { useLocalTranslation } from '../../locale';
import { import {
updateUnreadMsgsCount, fetchChannels,
unreadMsgsCountObs,
startMsgSSEStreamWithRetry,
inboxVisible, inboxVisible,
userIdObs,
liveSSEObs, liveSSEObs,
messageMapObs, messageMapObs,
selectedChannelNameObs, selectedChannelNameObs,
startMsgSSEStreamWithRetry,
unreadMsgsCountObs,
updateUnreadMsgsCount,
userIdObs,
} from '../observables'; } from '../observables';
import { InboxContent } from './InboxContent';
const useStyles = createStyles(({ token }) => { const useStyles = createStyles(({ token }) => {
return { return {
button: { button: {
@ -94,6 +93,12 @@ const InnerInbox = (props) => {
<CloseOutlined /> <CloseOutlined />
</div> </div>
); );
const renderContent = useCallback((content: string, contentType: string) => {
if (contentType === 'HTML') {
return <div dangerouslySetInnerHTML={{ __html: content }} />;
}
return content.slice(0, 100) + (content.length > 100 ? '...' : '');
}, []);
useEffect(() => { useEffect(() => {
const dispose = reaction( const dispose = reaction(
() => liveSSEObs.value, () => liveSSEObs.value,
@ -116,7 +121,7 @@ const InnerInbox = (props) => {
{data.title} {data.title}
</div> </div>
), ),
description: data.content.slice(0, 100) + (data.content.length > 100 ? '...' : ''), description: renderContent(data.content, data.contentType),
onClick: () => { onClick: () => {
inboxVisible.value = true; inboxVisible.value = true;
selectedChannelNameObs.value = data.channelName; selectedChannelNameObs.value = data.channelName;

View File

@ -7,12 +7,12 @@
* For more information, please refer to: https://www.nocobase.com/agreement. * For more information, please refer to: https://www.nocobase.com/agreement.
*/ */
import React from 'react';
import { SchemaComponent, css } from '@nocobase/client'; import { SchemaComponent, css } from '@nocobase/client';
import { useLocalTranslation, NAMESPACE } from '../../locale';
import { UsersSelect } from './UsersSelect';
import { UsersAddition } from './UsersAddition';
import { tval } from '@nocobase/utils/client'; import { tval } from '@nocobase/utils/client';
import React from 'react';
import { NAMESPACE, useLocalTranslation } from '../../locale';
import { UsersAddition } from './UsersAddition';
import { UsersSelect } from './UsersSelect';
export const MessageConfigForm = ({ variableOptions }) => { export const MessageConfigForm = ({ variableOptions }) => {
const { t } = useLocalTranslation(); const { t } = useLocalTranslation();
@ -84,6 +84,17 @@ export const MessageConfigForm = ({ variableOptions }) => {
useTypedConstant: ['string'], useTypedConstant: ['string'],
}, },
}, },
contentType: {
type: 'string',
title: `{{t("Content type")}}`,
'x-decorator': 'FormItem',
'x-component': 'Radio.Group',
enum: [
{ label: `{{t("Plain text")}}`, value: 'text' },
{ label: 'HTML', value: 'HTML' },
],
default: 'text',
},
content: { content: {
type: 'string', type: 'string',
required: true, required: true,

View File

@ -90,7 +90,12 @@ const MessageList = observer(() => {
}, [messages, selectedChannelName]); }, [messages, selectedChannelName]);
const title = Schema.compile(channelMapObs.value[selectedChannelName].title, { t: app.i18n.t }); const title = Schema.compile(channelMapObs.value[selectedChannelName].title, { t: app.i18n.t });
const renderContent = (content: string, contentType: string) => {
if (contentType === 'HTML') {
return <div dangerouslySetInnerHTML={{ __html: content }} />;
}
return content;
};
return ( return (
<ConfigProvider <ConfigProvider
theme={{ theme={{
@ -156,10 +161,7 @@ const MessageList = observer(() => {
> >
<Descriptions key={index} column={1}> <Descriptions key={index} column={1}>
<Descriptions.Item label={t('Content')}> <Descriptions.Item label={t('Content')}>
{' '} {renderContent(message.content, message.contentType)}
<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>
<Descriptions.Item label={t('Datetime')}>{dayjs(message.receiveTimestamp).fromNow()}</Descriptions.Item> <Descriptions.Item label={t('Datetime')}>{dayjs(message.receiveTimestamp).fromNow()}</Descriptions.Item>
<Descriptions.Item label={t('Status')}> <Descriptions.Item label={t('Status')}>

View File

@ -7,33 +7,32 @@
* For more information, please refer to: https://www.nocobase.com/agreement. * For more information, please refer to: https://www.nocobase.com/agreement.
*/ */
import React, { useEffect, useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { List, Badge, InfiniteScroll, NavBar, DotLoading } from 'antd-mobile';
import { observer } from '@formily/reactive-react'; import { observer } from '@formily/reactive-react';
import { useCurrentUserContext, css, useApp } from '@nocobase/client'; import { css, useApp, useCurrentUserContext } from '@nocobase/client';
import { useSearchParams } from 'react-router-dom';
import { dayjs } from '@nocobase/utils/client'; import { dayjs } from '@nocobase/utils/client';
import { Badge, DotLoading, InfiniteScroll, List, NavBar } from 'antd-mobile';
import React, { useCallback, useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Schema } from '@formily/react';
import { import {
MobilePageContentContainer,
MobilePageHeader, MobilePageHeader,
MobilePageProvider, MobilePageProvider,
MobilePageContentContainer,
useMobileTitle, useMobileTitle,
} from '@nocobase/plugin-mobile/client'; } from '@nocobase/plugin-mobile/client';
import { useLocalTranslation } from '../../../locale';
import { import {
userIdObs, fetchChannels,
fetchMessages,
selectedChannelNameObs, selectedChannelNameObs,
selectedChannelObs, selectedChannelObs,
selectedMessageListObs, selectedMessageListObs,
fetchChannels,
updateMessage,
fetchMessages,
showMsgLoadingMoreObs, showMsgLoadingMoreObs,
updateMessage,
userIdObs,
} from '../../observables'; } from '../../observables';
import { useLocalTranslation } from '../../../locale';
import InfiniteScrollContent from './InfiniteScrollContent'; import InfiniteScrollContent from './InfiniteScrollContent';
import { Schema } from '@formily/react';
const MobileMessagePageInner = () => { const MobileMessagePageInner = () => {
const app = useApp(); const app = useApp();
@ -100,6 +99,12 @@ const MobileMessagePageInner = () => {
}, [messages]); }, [messages]);
const title = Schema.compile(selectedChannelObs.value?.title, { t: app.i18n.t }) || t('Message'); const title = Schema.compile(selectedChannelObs.value?.title, { t: app.i18n.t }) || t('Message');
const renderContent = useCallback((content: string, contentType: string) => {
if (contentType === 'HTML') {
return <div dangerouslySetInnerHTML={{ __html: content }} />;
}
return content;
}, []);
return ( return (
<MobilePageProvider> <MobilePageProvider>
<MobilePageHeader> <MobilePageHeader>
@ -131,7 +136,7 @@ const MobileMessagePageInner = () => {
<Badge key={item.id} content={item.status === 'unread' ? Badge.dot : null} /> <Badge key={item.id} content={item.status === 'unread' ? Badge.dot : null} />
</div> </div>
} }
description={item.content} description={renderContent(item.content, item.contentType)}
extra={dayjs(item.receiveTimestamp).fromNow(true)} extra={dayjs(item.receiveTimestamp).fromNow(true)}
onClick={() => { onClick={() => {
onMessageClick(item); onMessageClick(item);

View File

@ -11,13 +11,7 @@ import { autorun, observable } from '@formily/reactive';
import { merge } from '@nocobase/utils/client'; import { merge } from '@nocobase/utils/client';
import { InAppMessagesDefinition, Message } from '../../types'; import { InAppMessagesDefinition, Message } from '../../types';
import { getAPIClient } from '../utils'; import { getAPIClient } from '../utils';
import { import { channelMapObs, channelStatusFilterObs, selectedChannelNameObs } from './channel';
channelMapObs,
channelStatusFilterObs,
fetchChannels,
InappChannelStatusEnum,
selectedChannelNameObs,
} from './channel';
import { userIdObs } from './user'; import { userIdObs } from './user';
export const messageMapObs = observable<{ value: Record<string, Message> }>({ value: {} }); export const messageMapObs = observable<{ value: Record<string, Message> }>({ value: {} });

View File

@ -7,14 +7,13 @@
* For more information, please refer to: https://www.nocobase.com/agreement. * For more information, please refer to: https://www.nocobase.com/agreement.
*/ */
import { BaseNotificationChannel, SendFnType } from '@nocobase/plugin-notification-manager';
import { Application } from '@nocobase/server'; import { Application } from '@nocobase/server';
import { SendFnType, BaseNotificationChannel } from '@nocobase/plugin-notification-manager';
import { InAppMessageFormValues } from '../types';
import { PassThrough } from 'stream'; import { PassThrough } from 'stream';
import { InAppMessagesDefinition as MessagesDefinition } from '../types'; import { InAppMessageFormValues, InAppMessagesDefinition as MessagesDefinition } from '../types';
import { parseUserSelectionConf } from './parseUserSelectionConf';
import defineMyInAppMessages from './defineMyInAppMessages';
import defineMyInAppChannels from './defineMyInAppChannels'; import defineMyInAppChannels from './defineMyInAppChannels';
import defineMyInAppMessages from './defineMyInAppMessages';
import { parseUserSelectionConf } from './parseUserSelectionConf';
type UserID = string; type UserID = string;
type ClientID = string; type ClientID = string;
@ -76,6 +75,7 @@ export default class InAppNotificationChannel extends BaseNotificationChannel {
saveMessageToDB = async ({ saveMessageToDB = async ({
content, content,
contentType,
status, status,
userId, userId,
title, title,
@ -85,6 +85,7 @@ export default class InAppNotificationChannel extends BaseNotificationChannel {
}: { }: {
content: string; content: string;
userId: number; userId: number;
contentType: 'text' | 'HTML';
title: string; title: string;
channelName: string; channelName: string;
status: 'read' | 'unread'; status: 'read' | 'unread';
@ -97,6 +98,7 @@ export default class InAppNotificationChannel extends BaseNotificationChannel {
content, content,
title, title,
channelName, channelName,
contentType,
status, status,
userId, userId,
receiveTimestamp: receiveTimestamp ?? Date.now(), receiveTimestamp: receiveTimestamp ?? Date.now(),
@ -109,7 +111,7 @@ export default class InAppNotificationChannel extends BaseNotificationChannel {
send: SendFnType<InAppMessageFormValues> = async (params) => { send: SendFnType<InAppMessageFormValues> = async (params) => {
const { channel, message, receivers } = params; const { channel, message, receivers } = params;
let userIds: number[]; let userIds: number[];
const { content, title, options = {} } = message; const { content, contentType, title, options = {} } = message;
const userRepo = this.app.db.getRepository('users'); const userRepo = this.app.db.getRepository('users');
if (receivers?.type === 'userId') { if (receivers?.type === 'userId') {
userIds = receivers.value; userIds = receivers.value;
@ -123,6 +125,7 @@ export default class InAppNotificationChannel extends BaseNotificationChannel {
content, content,
status: 'unread', status: 'unread',
userId, userId,
contentType,
channelName: channel.name, channelName: channel.name,
options, options,
}); });

View File

@ -0,0 +1,68 @@
/**
* 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 { Migration } from '@nocobase/server';
import { InAppMessagesDefinition } from '../../types';
export default class extends Migration {
on = 'afterLoad'; // 'beforeLoad' or 'afterLoad'
appVersion = '<1.6.24';
async up() {
const { db } = this.context;
const collection = db.getCollection(InAppMessagesDefinition.name);
if (!collection) {
return;
}
const Field = db.getRepository('fields');
const existed = await Field.count({
filter: {
name: 'contentType',
collectionName: InAppMessagesDefinition.name,
},
});
if (existed) {
return;
}
await db.getRepository('fields').create({
values: {
collectionName: InAppMessagesDefinition.name,
name: 'contentType',
type: 'string',
interface: 'select',
uiSchema: {
type: 'string',
title: '{{t("Content type")}}',
'x-component': 'Select',
'x-component-props': {
options: [
{ label: '{{t("Text")}}', value: 'text' },
{ label: '{{t("HTML")}}', value: 'HTML' },
],
},
},
options: {
enum: ['HTML', 'text'],
},
},
});
// Update existing records to have contentType = 'text'
// Update existing records to have 'map' as the default value
await db.getRepository(InAppMessagesDefinition.name).update({
filter: {
contentType: null,
},
values: {
contentType: 'text',
},
});
}
}

View File

@ -21,6 +21,7 @@ export interface Message {
title: string; title: string;
userId: string; userId: string;
channelName: string; channelName: string;
contentType: 'text' | 'HTML';
content: string; content: string;
receiveTimestamp: number; receiveTimestamp: number;
status: 'read' | 'unread'; status: 'read' | 'unread';
@ -35,6 +36,7 @@ export type SSEData = {
export interface InAppMessageFormValues { export interface InAppMessageFormValues {
receivers: string[]; receivers: string[];
content: string; content: string;
contentType: 'text' | 'HTML';
senderName: string; senderName: string;
senderId: string; senderId: string;
url: string; url: string;
@ -49,6 +51,7 @@ export const InAppMessagesDefinition = {
channelName: 'channelName', channelName: 'channelName',
userId: 'userId', userId: 'userId',
content: 'content', content: 'content',
contentType: 'contentType',
status: 'status', status: 'status',
title: 'title', title: 'title',
receiveTimestamp: 'receiveTimestamp', receiveTimestamp: 'receiveTimestamp',

View File

@ -8,7 +8,7 @@
*/ */
import { CollectionOptions } from '@nocobase/client'; import { CollectionOptions } from '@nocobase/client';
import { InAppMessagesDefinition, ChannelsDefinition } from './index'; import { ChannelsDefinition, InAppMessagesDefinition } from './index';
export const messageCollection: CollectionOptions = { export const messageCollection: CollectionOptions = {
name: InAppMessagesDefinition.name, name: InAppMessagesDefinition.name,
@ -61,6 +61,27 @@ export const messageCollection: CollectionOptions = {
required: true, required: true,
}, },
}, },
{
name: InAppMessagesDefinition.fieldNameMap.contentType,
type: 'string',
uiSchema: {
type: 'string',
title: '{{t("Content type")}}',
interface: 'select',
uiSchema: {
type: 'string',
title: '{{t("Content type")}}',
'x-component': 'Select',
'x-component-props': {
options: [
{ label: '{{t("Text")}}', value: 'text' },
{ label: '{{t("HTML")}}', value: 'HTML' },
],
},
required: true,
},
},
},
{ {
name: InAppMessagesDefinition.fieldNameMap.content, name: InAppMessagesDefinition.fieldNameMap.content,
type: 'text', type: 'text',