mirror of
https://gitee.com/nocobase/nocobase.git
synced 2025-07-02 11:12:20 +08:00
feat(notification-in-app-message): support in-app messages for mobile client (#5560)
feat(notification-in-app-message): support in-app messages for mobile client
This commit is contained in:
parent
a6f16acf1c
commit
ee512d7c9d
@ -182,7 +182,12 @@ export class OptionsParser {
|
|||||||
|
|
||||||
sortField.push(direction);
|
sortField.push(direction);
|
||||||
if (this.database.isMySQLCompatibleDialect()) {
|
if (this.database.isMySQLCompatibleDialect()) {
|
||||||
orderParams.push([Sequelize.fn('ISNULL', Sequelize.col(`${this.model.name}.${sortField[0]}`))]);
|
const fieldName = sortField[0];
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
if (this.model.fieldRawAttributesMap[fieldName]) {
|
||||||
|
orderParams.push([Sequelize.fn('ISNULL', Sequelize.col(`${this.model.name}.${sortField[0]}`))]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
orderParams.push(sortField);
|
orderParams.push(sortField);
|
||||||
}
|
}
|
||||||
|
@ -18,7 +18,7 @@
|
|||||||
"@types/react-dom": "17.x",
|
"@types/react-dom": "17.x",
|
||||||
"ahooks": "3.x",
|
"ahooks": "3.x",
|
||||||
"antd": "5.x",
|
"antd": "5.x",
|
||||||
"antd-mobile": "^5.29.1",
|
"antd-mobile": "^5.38",
|
||||||
"antd-style": "3.x",
|
"antd-style": "3.x",
|
||||||
"classnames": "2.x",
|
"classnames": "2.x",
|
||||||
"react": "18.x",
|
"react": "18.x",
|
||||||
|
@ -37,6 +37,7 @@ const InternalItem: React.FC<TabBarItemProps> = () => {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
padding-top: 5px;
|
||||||
`,
|
`,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
@ -22,7 +22,7 @@
|
|||||||
"@formily/shared": "2.x",
|
"@formily/shared": "2.x",
|
||||||
"@types/react": "17.x",
|
"@types/react": "17.x",
|
||||||
"@types/react-dom": "17.x",
|
"@types/react-dom": "17.x",
|
||||||
"antd-mobile": "^5.36.1",
|
"antd-mobile": "^5.38",
|
||||||
"re-resizable": "6.6.0",
|
"re-resizable": "6.6.0",
|
||||||
"react-device-detect": "2.2.3",
|
"react-device-detect": "2.2.3",
|
||||||
"@emotion/css": "11.x",
|
"@emotion/css": "11.x",
|
||||||
|
@ -47,7 +47,7 @@ export const MobileTabBarItem: FC<MobileTabBarItemProps> = (props) => {
|
|||||||
})}
|
})}
|
||||||
style={{ lineHeight: 1 }}
|
style={{ lineHeight: 1 }}
|
||||||
>
|
>
|
||||||
<Badge content={badge}>
|
<Badge content={badge} style={{ '--top': '5px' }}>
|
||||||
<span className={'adm-tab-bar-item-icon'}>{icon}</span>
|
<span className={'adm-tab-bar-item-icon'}>{icon}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
<span
|
<span
|
||||||
|
@ -1,8 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* 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 { upperFirst } from 'lodash';
|
import { upperFirst } from 'lodash';
|
||||||
|
import { merge } from '@nocobase/utils/client';
|
||||||
|
import { ISchema } from '@nocobase/client';
|
||||||
import { MobileRouteItem } from '../../../mobile-providers';
|
import { MobileRouteItem } from '../../../mobile-providers';
|
||||||
|
|
||||||
export function getMobileTabBarItemSchema(routeItem: MobileRouteItem) {
|
export function getMobileTabBarItemSchema(routeItem: MobileRouteItem) {
|
||||||
return {
|
const _schema = {
|
||||||
name: routeItem.id,
|
name: routeItem.id,
|
||||||
type: 'void',
|
type: 'void',
|
||||||
'x-decorator': 'BlockItem',
|
'x-decorator': 'BlockItem',
|
||||||
@ -18,5 +29,6 @@ export function getMobileTabBarItemSchema(routeItem: MobileRouteItem) {
|
|||||||
schemaUid: routeItem.schemaUid,
|
schemaUid: routeItem.schemaUid,
|
||||||
...(routeItem.options || {}),
|
...(routeItem.options || {}),
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
return merge(_schema, routeItem.options?.schema ?? {}) as ISchema;
|
||||||
}
|
}
|
||||||
|
@ -14,13 +14,18 @@ import { MobileTabBarItemProps, MobileTabBarItem } from '../../MobileTabBar.Item
|
|||||||
|
|
||||||
export interface MobileTabBarPageProps extends Omit<MobileTabBarItemProps, 'onClick' | 'selected'> {
|
export interface MobileTabBarPageProps extends Omit<MobileTabBarItemProps, 'onClick' | 'selected'> {
|
||||||
schemaUid: string;
|
schemaUid: string;
|
||||||
|
url?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MobileTabBarPage: FC<MobileTabBarPageProps> = (props) => {
|
export const MobileTabBarPage: FC<MobileTabBarPageProps> = (props) => {
|
||||||
const { schemaUid, ...rests } = props;
|
const { schemaUid, ...rests } = props;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const url = useMemo(() => `/page/${schemaUid}`, [schemaUid]);
|
const url = useMemo(() => {
|
||||||
|
if (schemaUid) return `/page/${schemaUid}`;
|
||||||
|
else if (rests.url) return `${rests.url}`;
|
||||||
|
else return '/';
|
||||||
|
}, [schemaUid, rests.url]);
|
||||||
const handleClick = useCallback(() => {
|
const handleClick = useCallback(() => {
|
||||||
navigate(url);
|
navigate(url);
|
||||||
}, [url, navigate]);
|
}, [url, navigate]);
|
||||||
|
@ -8,5 +8,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export * from './MobilePageContent';
|
export * from './MobilePageContent';
|
||||||
|
export * from './MobilePageContentContainer';
|
||||||
export * from './initializer';
|
export * from './initializer';
|
||||||
export * from './schema';
|
export * from './schema';
|
||||||
|
@ -12,6 +12,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"immer": "^10.1.1"
|
"immer": "^10.1.1"
|
||||||
},
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"antd-mobile": "^5.38"
|
||||||
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@formily/reactive": "^2",
|
"@formily/reactive": "^2",
|
||||||
"@formily/reactive-react": "^2",
|
"@formily/reactive-react": "^2",
|
||||||
@ -20,6 +23,5 @@
|
|||||||
"@nocobase/server": "1.x",
|
"@nocobase/server": "1.x",
|
||||||
"@nocobase/test": "1.x",
|
"@nocobase/test": "1.x",
|
||||||
"react-router-dom": "^6.x"
|
"react-router-dom": "^6.x"
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -51,7 +51,7 @@ export const ContentConfigForm = ({ variableOptions }) => {
|
|||||||
url: {
|
url: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
required: false,
|
required: false,
|
||||||
title: `{{t("Detail URL")}}`,
|
title: `{{t("PC detail URL")}}`,
|
||||||
'x-decorator': 'FormItem',
|
'x-decorator': 'FormItem',
|
||||||
'x-component': 'Variable.TextArea',
|
'x-component': 'Variable.TextArea',
|
||||||
'x-component-props': {
|
'x-component-props': {
|
||||||
@ -62,6 +62,20 @@ export const ContentConfigForm = ({ variableOptions }) => {
|
|||||||
'Support two types of links: internal links and external links. If using an internal link, the link starts with"/", for example, "/admin". If using an external link, the link starts with "http", for example, "https://example.com".',
|
'Support two types of links: internal links and external links. If using an internal link, the link starts with"/", for example, "/admin". If using an external link, the link starts with "http", for example, "https://example.com".',
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
mobileUrl: {
|
||||||
|
type: 'string',
|
||||||
|
required: false,
|
||||||
|
title: `{{t("Mobile detail URL")}}`,
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'Variable.TextArea',
|
||||||
|
'x-component-props': {
|
||||||
|
scope: variableOptions,
|
||||||
|
useTypedConstant: ['string'],
|
||||||
|
},
|
||||||
|
description: tval(
|
||||||
|
"Support two types of links: internal links and external links. If using an internal link, the link starts with '/', for example, '/m'. If using an external link, the link starts with 'http', for example, 'https://example.com'.",
|
||||||
|
),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* 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 { Tabs, ConfigProvider } from 'antd';
|
||||||
|
import { observer } from '@formily/reactive-react';
|
||||||
|
import { fetchChannels, channelStatusFilterObs, ChannelStatus } from '../observables';
|
||||||
|
import { useLocalTranslation } from '../../locale';
|
||||||
|
|
||||||
|
const _FilterTab = () => {
|
||||||
|
const { t } = useLocalTranslation();
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FilterTab = observer(_FilterTab);
|
||||||
|
export default FilterTab;
|
@ -17,7 +17,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect, useCallback } from 'react';
|
import React, { useEffect, useCallback } from 'react';
|
||||||
import { Badge, Button, ConfigProvider, Drawer, Tooltip } from 'antd';
|
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 { createStyles } from 'antd-style';
|
||||||
import { Icon } from '@nocobase/client';
|
import { Icon } from '@nocobase/client';
|
||||||
@ -32,6 +33,9 @@ import {
|
|||||||
startMsgSSEStreamWithRetry,
|
startMsgSSEStreamWithRetry,
|
||||||
inboxVisible,
|
inboxVisible,
|
||||||
userIdObs,
|
userIdObs,
|
||||||
|
liveSSEObs,
|
||||||
|
messageMapObs,
|
||||||
|
selectedChannelNameObs,
|
||||||
} from '../observables';
|
} from '../observables';
|
||||||
const useStyles = createStyles(({ token }) => {
|
const useStyles = createStyles(({ token }) => {
|
||||||
return {
|
return {
|
||||||
@ -61,7 +65,28 @@ const InnerInbox = (props) => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
startMsgSSEStreamWithRetry();
|
const disposes: Array<() => void> = [];
|
||||||
|
disposes.push(startMsgSSEStreamWithRetry());
|
||||||
|
const disposeAll = () => {
|
||||||
|
while (disposes.length > 0) {
|
||||||
|
const dispose = disposes.pop();
|
||||||
|
dispose && dispose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
disposes.push(startMsgSSEStreamWithRetry());
|
||||||
|
} else {
|
||||||
|
disposeAll();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||||
|
return () => {
|
||||||
|
disposeAll();
|
||||||
|
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
const DrawerTitle = <div style={{ padding: '0' }}>{t('Message')}</div>;
|
const DrawerTitle = <div style={{ padding: '0' }}>{t('Message')}</div>;
|
||||||
const CloseIcon = (
|
const CloseIcon = (
|
||||||
@ -69,6 +94,41 @@ const InnerInbox = (props) => {
|
|||||||
<CloseOutlined />
|
<CloseOutlined />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
const dispose = 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();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return dispose;
|
||||||
|
}, []);
|
||||||
return (
|
return (
|
||||||
<ConfigProvider
|
<ConfigProvider
|
||||||
theme={{
|
theme={{
|
||||||
@ -79,7 +139,7 @@ const InnerInbox = (props) => {
|
|||||||
<Badge count={unreadMsgsCountObs.value} size="small" offset={[-12, 14]}>
|
<Badge count={unreadMsgsCountObs.value} size="small" offset={[-12, 14]}>
|
||||||
<Button
|
<Button
|
||||||
className={styles.button}
|
className={styles.button}
|
||||||
title={'Apps'}
|
title={t('Message')}
|
||||||
icon={<Icon type={'MailOutlined'} />}
|
icon={<Icon type={'MailOutlined'} />}
|
||||||
onClick={onIconClick}
|
onClick={onIconClick}
|
||||||
/>
|
/>
|
||||||
|
@ -26,7 +26,8 @@ import {
|
|||||||
ChannelStatus,
|
ChannelStatus,
|
||||||
} from '../observables';
|
} from '../observables';
|
||||||
|
|
||||||
import { MessageList } from './MessageList';
|
import MessageList from './MessageList';
|
||||||
|
import FilterTab from './FilterTab';
|
||||||
|
|
||||||
const InnerInboxContent = () => {
|
const InnerInboxContent = () => {
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
@ -61,34 +62,6 @@ const InnerInboxContent = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : null;
|
) : 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 (
|
return (
|
||||||
<Layout style={{ height: '100%' }}>
|
<Layout style={{ height: '100%' }}>
|
||||||
<Layout.Sider
|
<Layout.Sider
|
||||||
|
@ -100,7 +100,7 @@ export const MessageConfigForm = ({ variableOptions }) => {
|
|||||||
url: {
|
url: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
required: false,
|
required: false,
|
||||||
title: `{{t("Detail URL")}}`,
|
title: `{{t("PC detail URL")}}`,
|
||||||
'x-decorator': 'FormItem',
|
'x-decorator': 'FormItem',
|
||||||
'x-component': 'Variable.TextArea',
|
'x-component': 'Variable.TextArea',
|
||||||
'x-component-props': {
|
'x-component-props': {
|
||||||
@ -111,6 +111,20 @@ export const MessageConfigForm = ({ variableOptions }) => {
|
|||||||
'Support two types of links: internal links and external links. If using an internal link, the link starts with"/", for example, "/admin". If using an external link, the link starts with "http", for example, "https://example.com".',
|
'Support two types of links: internal links and external links. If using an internal link, the link starts with"/", for example, "/admin". If using an external link, the link starts with "http", for example, "https://example.com".',
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
mobileUrl: {
|
||||||
|
type: 'string',
|
||||||
|
required: false,
|
||||||
|
title: `{{t("Mobile detail URL")}}`,
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'Variable.TextArea',
|
||||||
|
'x-component-props': {
|
||||||
|
scope: variableOptions,
|
||||||
|
useTypedConstant: ['string'],
|
||||||
|
},
|
||||||
|
description: tval(
|
||||||
|
'Support two types of links: internal links and external links. If using an internal link, the link starts with"/", for example, "/m". If using an external link, the link starts with "http", for example, "https://example.com".',
|
||||||
|
),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -26,7 +26,7 @@ import {
|
|||||||
inboxVisible,
|
inboxVisible,
|
||||||
} from '../observables';
|
} from '../observables';
|
||||||
|
|
||||||
export const MessageList = observer(() => {
|
const MessageList = observer(() => {
|
||||||
const { t } = useLocalTranslation();
|
const { t } = useLocalTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
@ -172,3 +172,5 @@ export const MessageList = observer(() => {
|
|||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export default MessageList;
|
||||||
|
@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* 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, { useRef, useEffect, useState } from 'react';
|
||||||
|
import { observer } from '@formily/reactive-react';
|
||||||
|
import { reaction } from '@formily/reactive';
|
||||||
|
import { List, Badge, InfiniteScroll, ListRef } from 'antd-mobile';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { dayjs } from '@nocobase/utils/client';
|
||||||
|
import InfiniteScrollContent from './InfiniteScrollContent';
|
||||||
|
import { channelListObs, channelStatusFilterObs, showChannelLoadingMoreObs, fetchChannels } from '../../observables';
|
||||||
|
const InternalChannelList = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const channels = channelListObs.value;
|
||||||
|
const listRef = useRef<ListRef>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const dispose = reaction(
|
||||||
|
() => channelStatusFilterObs.value,
|
||||||
|
() => {
|
||||||
|
const ele = document.querySelector('.mobile-page-content');
|
||||||
|
if (ele) ele.scrollTop = 0;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return dispose;
|
||||||
|
}, []);
|
||||||
|
const [fetctChannelsStatus, setFetchChannelsStatus] = useState<'success' | 'loading' | 'failure'>('success');
|
||||||
|
|
||||||
|
const onLoadChannelsMore = async () => {
|
||||||
|
try {
|
||||||
|
setFetchChannelsStatus('loading');
|
||||||
|
const filter: Record<string, any> = {};
|
||||||
|
const lastChannel = channels[channels.length - 1];
|
||||||
|
if (lastChannel?.latestMsgReceiveTimestamp) {
|
||||||
|
filter.latestMsgReceiveTimestamp = {
|
||||||
|
$lt: lastChannel.latestMsgReceiveTimestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const res = await fetchChannels({ filter, limit: 30 });
|
||||||
|
setFetchChannelsStatus('success');
|
||||||
|
return res;
|
||||||
|
} catch {
|
||||||
|
setFetchChannelsStatus('failure');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<List
|
||||||
|
ref={listRef}
|
||||||
|
style={{
|
||||||
|
'--border-top': 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{channelListObs.value.map((item) => {
|
||||||
|
return (
|
||||||
|
<List.Item
|
||||||
|
key={item.name}
|
||||||
|
onClick={() => {
|
||||||
|
navigate(`/page/in-app-message/messages?channel=${item.name}`);
|
||||||
|
}}
|
||||||
|
description={
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<div>{item.latestMsgTitle}</div>
|
||||||
|
<div>
|
||||||
|
<Badge
|
||||||
|
style={{ border: 'none' }}
|
||||||
|
content={
|
||||||
|
channelStatusFilterObs.value !== 'read' && item.unreadMsgCnt > 0 ? item.unreadMsgCnt : null
|
||||||
|
}
|
||||||
|
></Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div> {item.title}</div>
|
||||||
|
<div style={{ color: 'var(--adm-color-weak)', fontSize: 'var(--adm-font-size-main)' }}>
|
||||||
|
{dayjs(item.latestMsgReceiveTimestamp).fromNow(true)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</List.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<InfiniteScroll
|
||||||
|
loadMore={onLoadChannelsMore}
|
||||||
|
hasMore={fetctChannelsStatus !== 'failure' && showChannelLoadingMoreObs.value}
|
||||||
|
>
|
||||||
|
<InfiniteScrollContent
|
||||||
|
loadMoreStatus={fetctChannelsStatus}
|
||||||
|
hasMore={showChannelLoadingMoreObs.value}
|
||||||
|
retry={onLoadChannelsMore}
|
||||||
|
/>
|
||||||
|
</InfiniteScroll>
|
||||||
|
</List>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ChannelList = observer(InternalChannelList, { displayName: 'ChannelList' });
|
@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* 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, { useEffect, useCallback } from 'react';
|
||||||
|
import { Tabs } from 'antd-mobile';
|
||||||
|
import { observer } from '@formily/reactive-react';
|
||||||
|
import { useCurrentUserContext, css } from '@nocobase/client';
|
||||||
|
import {
|
||||||
|
MobilePageHeader,
|
||||||
|
MobilePageNavigationBar,
|
||||||
|
MobilePageProvider,
|
||||||
|
MobilePageContentContainer,
|
||||||
|
} from '@nocobase/plugin-mobile/client';
|
||||||
|
import { userIdObs, fetchChannels, ChannelStatus, channelStatusFilterObs } from '../../observables';
|
||||||
|
import { ChannelList } from './ChannelList';
|
||||||
|
import { useLocalTranslation } from '../../../locale';
|
||||||
|
const MobileMessageBoxInner = () => {
|
||||||
|
const { t } = useLocalTranslation();
|
||||||
|
const ctx = useCurrentUserContext();
|
||||||
|
const currUserId = ctx.data?.data?.id;
|
||||||
|
useEffect(() => {
|
||||||
|
userIdObs.value = currUserId ?? null;
|
||||||
|
}, [currUserId]);
|
||||||
|
useEffect(() => {
|
||||||
|
fetchChannels({});
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<MobilePageProvider>
|
||||||
|
<MobilePageHeader>
|
||||||
|
<MobilePageNavigationBar />
|
||||||
|
<Tabs
|
||||||
|
className={css({
|
||||||
|
'.adm-tabs-header': {
|
||||||
|
borderBottomWidth: 0,
|
||||||
|
},
|
||||||
|
'.adm-tabs-tab': {
|
||||||
|
height: 49,
|
||||||
|
padding: '10px 0 10px',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
activeKey={channelStatusFilterObs.value}
|
||||||
|
activeLineMode={'auto'}
|
||||||
|
onChange={(key: ChannelStatus) => {
|
||||||
|
channelStatusFilterObs.value = key;
|
||||||
|
fetchChannels({});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tabs.Tab title={t('All')} key="all" />
|
||||||
|
<Tabs.Tab title={t('Unread')} key="unread" />
|
||||||
|
<Tabs.Tab title={t('Read')} key="read" />
|
||||||
|
</Tabs>
|
||||||
|
</MobilePageHeader>
|
||||||
|
<MobilePageContentContainer>
|
||||||
|
<ChannelList />
|
||||||
|
</MobilePageContentContainer>
|
||||||
|
</MobilePageProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MobileChannelPage = observer(MobileMessageBoxInner, { displayName: 'MobileChannelPage' });
|
@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* 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 { useLocalTranslation } from '../../../locale';
|
||||||
|
import { DotLoading, Button } from 'antd-mobile';
|
||||||
|
export default function ({
|
||||||
|
hasMore,
|
||||||
|
loadMoreStatus,
|
||||||
|
retry,
|
||||||
|
}: {
|
||||||
|
hasMore: boolean;
|
||||||
|
loadMoreStatus: 'loading' | 'success' | 'failure';
|
||||||
|
retry: () => any;
|
||||||
|
}) {
|
||||||
|
const { t } = useLocalTranslation();
|
||||||
|
if (loadMoreStatus === 'loading')
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span>{t('Loading')}</span>
|
||||||
|
<DotLoading />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
else if (!hasMore) return <span>{t('No more')}</span>;
|
||||||
|
else if (loadMoreStatus === 'failure')
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span>{t('Loading failed,')}</span>
|
||||||
|
<span style={{ marginLeft: '5px', color: 'var(--adm-color-primary)', cursor: 'pointer' }} onClick={retry}>
|
||||||
|
{t('please reload')}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
else return null;
|
||||||
|
}
|
@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* 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, { 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 { useCurrentUserContext, css } from '@nocobase/client';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { dayjs } from '@nocobase/utils/client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
MobilePageHeader,
|
||||||
|
MobilePageProvider,
|
||||||
|
MobilePageContentContainer,
|
||||||
|
useMobileTitle,
|
||||||
|
} from '@nocobase/plugin-mobile/client';
|
||||||
|
import {
|
||||||
|
userIdObs,
|
||||||
|
selectedChannelNameObs,
|
||||||
|
selectedChannelObs,
|
||||||
|
selectedMessageListObs,
|
||||||
|
fetchChannels,
|
||||||
|
updateMessage,
|
||||||
|
fetchMessages,
|
||||||
|
showMsgLoadingMoreObs,
|
||||||
|
} from '../../observables';
|
||||||
|
import { useLocalTranslation } from '../../../locale';
|
||||||
|
import InfiniteScrollContent from './InfiniteScrollContent';
|
||||||
|
const MobileMessagePageInner = () => {
|
||||||
|
const { t } = useLocalTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const ctx = useCurrentUserContext();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const channelName = searchParams.get('channel');
|
||||||
|
useEffect(() => {
|
||||||
|
const effect = async () => {
|
||||||
|
if (channelName) {
|
||||||
|
await fetchChannels({ filter: { name: channelName } });
|
||||||
|
selectedChannelNameObs.value = channelName;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
effect();
|
||||||
|
}, [channelName]);
|
||||||
|
|
||||||
|
const currUserId = ctx.data?.data?.id;
|
||||||
|
useEffect(() => {
|
||||||
|
userIdObs.value = currUserId ?? null;
|
||||||
|
}, [currUserId]);
|
||||||
|
const messages = selectedMessageListObs.value;
|
||||||
|
const viewMessageDetail = (message) => {
|
||||||
|
const url = message.options?.mobileUrl;
|
||||||
|
if (url) {
|
||||||
|
if (url.startsWith('/m/')) navigate(url.substring(2));
|
||||||
|
else if (url.startsWith('/')) navigate(url);
|
||||||
|
else {
|
||||||
|
window.location.href = url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onMessageClick = (message) => {
|
||||||
|
updateMessage({
|
||||||
|
filterByTk: message.id,
|
||||||
|
values: {
|
||||||
|
status: 'read',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
viewMessageDetail(message);
|
||||||
|
};
|
||||||
|
const [fetchMsgStatus, setFecthMsgStatus] = useState<'success' | 'loading' | 'failure'>('success');
|
||||||
|
|
||||||
|
const onLoadMessagesMore = useCallback(async () => {
|
||||||
|
const filter: Record<string, any> = {};
|
||||||
|
const lastMessage = messages[messages.length - 1];
|
||||||
|
if (lastMessage) {
|
||||||
|
filter.receiveTimestamp = {
|
||||||
|
$lt: lastMessage.receiveTimestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (selectedChannelNameObs.value) {
|
||||||
|
filter.channelName = selectedChannelNameObs.value;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setFecthMsgStatus('loading');
|
||||||
|
const res = await fetchMessages({ filter, limit: 30 });
|
||||||
|
setFecthMsgStatus('success');
|
||||||
|
return res;
|
||||||
|
} catch {
|
||||||
|
setFecthMsgStatus('failure');
|
||||||
|
}
|
||||||
|
}, [messages]);
|
||||||
|
const title = selectedChannelObs.value?.title || t('Message');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MobilePageProvider>
|
||||||
|
<MobilePageHeader>
|
||||||
|
<NavBar onBack={() => navigate('/page/in-app-message')}>{title}</NavBar>
|
||||||
|
</MobilePageHeader>
|
||||||
|
<MobilePageContentContainer>
|
||||||
|
<div
|
||||||
|
style={{ height: '100%', overflowY: 'auto' }}
|
||||||
|
className={css({
|
||||||
|
'.adm-list-item-content-main': {
|
||||||
|
overflow: 'hidden',
|
||||||
|
wordWrap: 'break-word',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<List
|
||||||
|
style={{
|
||||||
|
'--border-top': 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{messages.map((item) => {
|
||||||
|
return (
|
||||||
|
<List.Item
|
||||||
|
key={item.id}
|
||||||
|
prefix={
|
||||||
|
<div style={{ width: '15px' }}>
|
||||||
|
<Badge key={item.id} content={item.status === 'unread' ? Badge.dot : null} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
description={item.content}
|
||||||
|
extra={dayjs(item.receiveTimestamp).fromNow(true)}
|
||||||
|
onClick={() => {
|
||||||
|
onMessageClick(item);
|
||||||
|
}}
|
||||||
|
arrowIcon={item.options?.mobileUrl ? true : false}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</List.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<InfiniteScroll
|
||||||
|
loadMore={onLoadMessagesMore}
|
||||||
|
hasMore={fetchMsgStatus !== 'failure' && showMsgLoadingMoreObs.value}
|
||||||
|
>
|
||||||
|
<InfiniteScrollContent
|
||||||
|
loadMoreStatus={fetchMsgStatus}
|
||||||
|
hasMore={showMsgLoadingMoreObs.value}
|
||||||
|
retry={onLoadMessagesMore}
|
||||||
|
/>
|
||||||
|
</InfiniteScroll>
|
||||||
|
</List>
|
||||||
|
</div>
|
||||||
|
</MobilePageContentContainer>
|
||||||
|
</MobilePageProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MobileMessagePage = observer(MobileMessagePageInner, { displayName: 'MobileMessagePage' });
|
@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* 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, { useEffect } from 'react';
|
||||||
|
import { observer } from '@formily/reactive-react';
|
||||||
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import { MobileTabBarItem } from '@nocobase/plugin-mobile/client';
|
||||||
|
import { unreadMsgsCountObs, startMsgSSEStreamWithRetry, updateUnreadMsgsCount } from '../../observables';
|
||||||
|
|
||||||
|
const InnerMobileTabBarMessageItem = (props) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const onClick = () => {
|
||||||
|
navigate('/page/in-app-message');
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
startMsgSSEStreamWithRetry();
|
||||||
|
updateUnreadMsgsCount();
|
||||||
|
}, []);
|
||||||
|
const selected = props.url && location.pathname.startsWith(props.url);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MobileTabBarItem
|
||||||
|
{...{
|
||||||
|
...props,
|
||||||
|
onClick,
|
||||||
|
badge: unreadMsgsCountObs.value && unreadMsgsCountObs.value > 0 ? unreadMsgsCountObs.value : undefined,
|
||||||
|
selected,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MobileTabBarMessageItem = observer(InnerMobileTabBarMessageItem, {
|
||||||
|
displayName: 'MobileTabBarMessageItem',
|
||||||
|
});
|
@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* 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 { SchemaInitializerItemType } from '@nocobase/client';
|
||||||
|
import { useMobileRoutes, MobileRouteItem } from '@nocobase/plugin-mobile/client';
|
||||||
|
import { uid } from '@formily/shared';
|
||||||
|
import { Toast } from 'antd-mobile';
|
||||||
|
import { useLocalTranslation } from '../../../locale';
|
||||||
|
export const messageSchemaInitializerItem: SchemaInitializerItemType = {
|
||||||
|
name: 'message-schema',
|
||||||
|
type: 'item',
|
||||||
|
useComponentProps() {
|
||||||
|
const { t } = useLocalTranslation();
|
||||||
|
const { resource, refresh, schemaResource } = useMobileRoutes();
|
||||||
|
return {
|
||||||
|
isItem: true,
|
||||||
|
title: t('Message'),
|
||||||
|
badge: 10,
|
||||||
|
async onClick(values) {
|
||||||
|
const res = await resource.list();
|
||||||
|
if (Array.isArray(res?.data?.data)) {
|
||||||
|
const findIndex = res?.data?.data.findIndex((route) => route?.options?.url === `/page/in-app-message`);
|
||||||
|
if (findIndex > -1) {
|
||||||
|
Toast.show({
|
||||||
|
icon: 'fail',
|
||||||
|
content: t('The message page has already been created.'),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { data } = await resource.create({
|
||||||
|
values: {
|
||||||
|
type: 'page',
|
||||||
|
title: t('Message'),
|
||||||
|
icon: 'mailoutlined',
|
||||||
|
options: {
|
||||||
|
url: `/page/in-app-message`,
|
||||||
|
schema: {
|
||||||
|
'x-component': 'MobileTabBarMessageItem',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'page',
|
||||||
|
title: t('Message'),
|
||||||
|
icon: 'mailoutlined',
|
||||||
|
options: {
|
||||||
|
url: `/page/in-app-message/messages`,
|
||||||
|
itemSchema: {
|
||||||
|
name: uid(),
|
||||||
|
'x-decorator': 'BlockItem',
|
||||||
|
'x-settings': `mobile:tab-bar:page`,
|
||||||
|
'x-component': 'MobileTabBarMessageItem',
|
||||||
|
'x-toolbar-props': {
|
||||||
|
showBorder: false,
|
||||||
|
showBackground: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as MobileRouteItem,
|
||||||
|
});
|
||||||
|
const parentId = data.data.id;
|
||||||
|
refresh();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
@ -10,11 +10,16 @@
|
|||||||
import { Plugin } from '@nocobase/client';
|
import { Plugin } from '@nocobase/client';
|
||||||
import { MessageManagerProvider } from './MessageManagerProvider';
|
import { MessageManagerProvider } from './MessageManagerProvider';
|
||||||
import NotificationManager from '@nocobase/plugin-notification-manager/client';
|
import NotificationManager from '@nocobase/plugin-notification-manager/client';
|
||||||
|
import MobileManager from '@nocobase/plugin-mobile/client';
|
||||||
import { tval } from '@nocobase/utils/client';
|
import { tval } from '@nocobase/utils/client';
|
||||||
import { MessageConfigForm } from './components/MessageConfigForm';
|
import { MessageConfigForm } from './components/MessageConfigForm';
|
||||||
import { ContentConfigForm } from './components/ContentConfigForm';
|
import { ContentConfigForm } from './components/ContentConfigForm';
|
||||||
import { NAMESPACE } from '../locale';
|
import { NAMESPACE } from '../locale';
|
||||||
import { setAPIClient } from './utils';
|
import { setAPIClient } from './utils';
|
||||||
|
import { messageSchemaInitializerItem } from './components/mobile/messageSchemaInitializerItem';
|
||||||
|
import { MobileChannelPage } from './components/mobile/ChannelPage';
|
||||||
|
import { MobileMessagePage } from './components/mobile/MessagePage';
|
||||||
|
import { MobileTabBarMessageItem } from './components/mobile/MobileTabBarMessageItem';
|
||||||
export class PluginNotificationInAppClient extends Plugin {
|
export class PluginNotificationInAppClient extends Plugin {
|
||||||
async afterAdd() {}
|
async afterAdd() {}
|
||||||
|
|
||||||
@ -38,6 +43,26 @@ export class PluginNotificationInAppClient extends Plugin {
|
|||||||
deletable: true,
|
deletable: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const mobileManager = this.pm.get(MobileManager);
|
||||||
|
this.app.schemaInitializerManager.addItem(
|
||||||
|
'mobile:tab-bar',
|
||||||
|
'notification-in-app-message',
|
||||||
|
messageSchemaInitializerItem,
|
||||||
|
);
|
||||||
|
this.app.addComponents({ MobileTabBarMessageItem: MobileTabBarMessageItem });
|
||||||
|
if (mobileManager.mobileRouter) {
|
||||||
|
mobileManager.mobileRouter.add('mobile.page.in-app-message', {
|
||||||
|
path: '/page/in-app-message',
|
||||||
|
});
|
||||||
|
mobileManager.mobileRouter.add('mobile.page.in-app-message.channels', {
|
||||||
|
path: '/page/in-app-message',
|
||||||
|
Component: MobileChannelPage,
|
||||||
|
});
|
||||||
|
mobileManager.mobileRouter.add('mobile.page.in-app-message.messages', {
|
||||||
|
path: '/page/in-app-message/messages',
|
||||||
|
Component: MobileMessagePage,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -40,6 +40,11 @@ export const showChannelLoadingMoreObs = observable.computed(() => {
|
|||||||
else return false;
|
else return false;
|
||||||
}) as { value: boolean };
|
}) as { value: boolean };
|
||||||
export const selectedChannelNameObs = observable<{ value: string | null }>({ value: null });
|
export const selectedChannelNameObs = observable<{ value: string | null }>({ value: null });
|
||||||
|
export const selectedChannelObs = observable.computed(() => {
|
||||||
|
if (selectedChannelNameObs.value && channelMapObs.value && channelMapObs.value[selectedChannelNameObs.value])
|
||||||
|
return channelMapObs.value[selectedChannelNameObs.value];
|
||||||
|
else return null;
|
||||||
|
}) as { value: Channel };
|
||||||
|
|
||||||
export const fetchChannels = async (params: any) => {
|
export const fetchChannels = async (params: any) => {
|
||||||
const apiClient = getAPIClient();
|
const apiClient = getAPIClient();
|
||||||
|
@ -6,18 +6,15 @@
|
|||||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||||
* 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 { observable, autorun, reaction } from '@formily/reactive';
|
import { observable, reaction } from '@formily/reactive';
|
||||||
import { notification } from 'antd';
|
|
||||||
import { SSEData } from '../../types';
|
import { SSEData } from '../../types';
|
||||||
import { messageMapObs, updateUnreadMsgsCount } from './message';
|
import { messageMapObs, updateUnreadMsgsCount } from './message';
|
||||||
import { channelMapObs, fetchChannels, selectedChannelNameObs } from './channel';
|
import { fetchChannels } from './channel';
|
||||||
import { inboxVisible } from './inbox';
|
|
||||||
import { getAPIClient } from '../utils';
|
import { getAPIClient } from '../utils';
|
||||||
import { uid } from '@nocobase/utils/client';
|
import { uid } from '@nocobase/utils/client';
|
||||||
|
|
||||||
export const liveSSEObs = observable<{ value: SSEData | null }>({ value: null });
|
export const liveSSEObs = observable<{ value: SSEData | null }>({ value: null });
|
||||||
|
|
||||||
reaction(
|
reaction(
|
||||||
() => liveSSEObs.value,
|
() => liveSSEObs.value,
|
||||||
(sseData) => {
|
(sseData) => {
|
||||||
@ -26,41 +23,27 @@ reaction(
|
|||||||
if (['message:created', 'message:updated'].includes(sseData.type)) {
|
if (['message:created', 'message:updated'].includes(sseData.type)) {
|
||||||
const { data } = sseData;
|
const { data } = sseData;
|
||||||
messageMapObs.value[data.id] = data;
|
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 } });
|
fetchChannels({ filter: { name: data.channelName } });
|
||||||
updateUnreadMsgsCount();
|
updateUnreadMsgsCount();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
export const startMsgSSEStreamWithRetry: () => () => void = () => {
|
||||||
export const startMsgSSEStreamWithRetry = async () => {
|
|
||||||
let retryTimes = 0;
|
let retryTimes = 0;
|
||||||
|
const controller = new AbortController();
|
||||||
|
let disposed = false;
|
||||||
|
const dispose = () => {
|
||||||
|
disposed = true;
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
|
||||||
const clientId = uid();
|
const clientId = uid();
|
||||||
const createMsgSSEConnection = async (clientId: string) => {
|
const createMsgSSEConnection = async (clientId: string) => {
|
||||||
const apiClient = getAPIClient();
|
const apiClient = getAPIClient();
|
||||||
const res = await apiClient.silent().request({
|
const res = await apiClient.silent().request({
|
||||||
url: 'myInAppMessages:sse',
|
url: 'myInAppMessages:sse',
|
||||||
method: 'get',
|
method: 'get',
|
||||||
|
signal: controller.signal,
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'text/event-stream',
|
Accept: 'text/event-stream',
|
||||||
},
|
},
|
||||||
@ -93,10 +76,11 @@ export const startMsgSSEStreamWithRetry = async () => {
|
|||||||
const nextDelay = retryTimes < 6 ? 1000 * Math.pow(2, retryTimes) : 60000;
|
const nextDelay = retryTimes < 6 ? 1000 * Math.pow(2, retryTimes) : 60000;
|
||||||
retryTimes++;
|
retryTimes++;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
connectWithRetry();
|
if (!disposed) connectWithRetry();
|
||||||
}, nextDelay);
|
}, nextDelay);
|
||||||
return { error };
|
return { error };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
connectWithRetry();
|
connectWithRetry();
|
||||||
|
return dispose;
|
||||||
};
|
};
|
@ -1,7 +1,11 @@
|
|||||||
{
|
{
|
||||||
"Inbox": "Inbox",
|
"Inbox": "Inbox",
|
||||||
"Message": "Message",
|
"Message": "Message",
|
||||||
|
"Loading": "Loading",
|
||||||
"Loading more": "Loading more",
|
"Loading more": "Loading more",
|
||||||
|
"No more": "No more",
|
||||||
|
"Loading failed,": "Loading failed,",
|
||||||
|
"please reload": "please reload",
|
||||||
"Detail": "Detail",
|
"Detail": "Detail",
|
||||||
"Content": "Content",
|
"Content": "Content",
|
||||||
"Datetime": "Datetime",
|
"Datetime": "Datetime",
|
||||||
@ -15,8 +19,10 @@
|
|||||||
"Message group name": "Message group name",
|
"Message group name": "Message group name",
|
||||||
"Message title": "Message title",
|
"Message title": "Message title",
|
||||||
"Message content": "Message content",
|
"Message content": "Message content",
|
||||||
"Inapp Message": "Inapp Message",
|
"PC detail URL": "PC detail URL",
|
||||||
"Detail URL": "Detail URL",
|
|
||||||
"Support two types of links: internal links and external links. If using an internal link, the link starts with\"/\", for example, \"/admin\". If using an external link, the link starts with \"http\", for example, \"https://example.com\".": "Support two types of links: internal links and external links. If using an internal link, the link starts with \"/\", for example, \"/admin\". If using an external link, the link starts with \"http\", for example, \"https://example.com\".",
|
"Support two types of links: internal links and external links. If using an internal link, the link starts with\"/\", for example, \"/admin\". If using an external link, the link starts with \"http\", for example, \"https://example.com\".": "Support two types of links: internal links and external links. If using an internal link, the link starts with \"/\", for example, \"/admin\". If using an external link, the link starts with \"http\", for example, \"https://example.com\".",
|
||||||
"Mark as read": "Mark as read"
|
"Mark as read": "Mark as read",
|
||||||
|
"Support two types of links: internal links and external links. If using an internal link, the link starts with\"/\", for example, \"/m\". If using an external link, the link starts with \"http\", for example, \"https://example.com\".": "Support two types of links: internal links and external links. If using an internal link, the link starts with \"/\", for example, \"/m\". If using an external link, the link starts with \"http\", for example, \"https://example.com\".",
|
||||||
|
"Mobile detail URL": "Mobile detail URL",
|
||||||
|
"The message page has already been created.": "The message page has already been created."
|
||||||
}
|
}
|
||||||
|
@ -17,7 +17,7 @@ export function lang(key: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function generateNTemplate(key: string) {
|
export function generateNTemplate(key: string) {
|
||||||
return `{{t('${key}', { ns: '${NAMESPACE}', nsMode: 'fallback' })}}`;
|
return `{{t('${key}', { ns: ['${NAMESPACE}', 'client'], nsMode: 'fallback' })}}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useLocalTranslation() {
|
export function useLocalTranslation() {
|
||||||
|
@ -1,7 +1,11 @@
|
|||||||
{
|
{
|
||||||
"Inbox": "收信箱",
|
"Inbox": "收信箱",
|
||||||
"Message": "消息",
|
"Message": "消息",
|
||||||
|
"Loading": "加载中",
|
||||||
"Loading more": "加载更多",
|
"Loading more": "加载更多",
|
||||||
|
"No more": "没有更多了",
|
||||||
|
"Loading failed,": "加载失败,",
|
||||||
|
"please reload": "重新加载",
|
||||||
"Detail": "详情",
|
"Detail": "详情",
|
||||||
"Content": "内容",
|
"Content": "内容",
|
||||||
"Datetime": "时间",
|
"Datetime": "时间",
|
||||||
@ -14,8 +18,11 @@
|
|||||||
"Message group name": "消息分组名称",
|
"Message group name": "消息分组名称",
|
||||||
"Message title": "消息标题",
|
"Message title": "消息标题",
|
||||||
"Message content": "消息内容",
|
"Message content": "消息内容",
|
||||||
"Inapp Message": "站内信",
|
"detail URL": "详情链接",
|
||||||
"Detail URL": "详情链接",
|
"PC detail URL": "PC端详情链接",
|
||||||
"Support two types of links: internal links and external links. If using an internal link, the link starts with\"/\", for example, \"/admin\". If using an external link, the link starts with \"http\", for example, \"https://example.com\".": "支持两种链接类型:内部链接和外部链接。如果使用内部链接,链接以“/”开头,例如“/admin”。如果使用外部链接,链接以“http”开头,例如“https://example.com”。",
|
"Support two types of links: internal links and external links. If using an internal link, the link starts with\"/\", for example, \"/admin\". If using an external link, the link starts with \"http\", for example, \"https://example.com\".": "支持两种链接类型:内部链接和外部链接。如果使用内部链接,链接以“/”开头,例如“/admin”。如果使用外部链接,链接以“http”开头,例如“https://example.com”。",
|
||||||
"Mark as read": "标记为已读"
|
"Mark as read": "标记为已读",
|
||||||
|
"Support two types of links: internal links and external links. If using an internal link, the link starts with\"/\", for example, \"/m\". If using an external link, the link starts with \"http\", for example, \"https://example.com\".": "支持两种链接类型:内部链接和外部链接。如果使用内部链接,链接以“/”开头,例如“/m”。如果使用外部链接,链接以“http”开头,例如“https://example.com”。",
|
||||||
|
"Mobile detail URL": "移动端详情链接",
|
||||||
|
"The message page has already been created.": "站内信页面已创建。"
|
||||||
}
|
}
|
||||||
|
@ -124,12 +124,13 @@ export default function defineMyInAppChannels({ app }: { app: Application }) {
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
order: [[Sequelize.literal('latestMsgReceiveTimestamp'), 'DESC']],
|
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
where: {
|
where: {
|
||||||
[Op.and]: [userFilter, latestMsgReceiveTSFilter, channelIdFilter, channelStatusFilter].filter(Boolean),
|
[Op.and]: [userFilter, latestMsgReceiveTSFilter, channelIdFilter, channelStatusFilter].filter(Boolean),
|
||||||
},
|
},
|
||||||
|
sort: ['-latestMsgReceiveTimestamp'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const countRes = channelsRepo.count({
|
const countRes = channelsRepo.count({
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
where: {
|
where: {
|
||||||
|
@ -13,4 +13,5 @@ export {
|
|||||||
ChannelsCollectionDefinition,
|
ChannelsCollectionDefinition,
|
||||||
BaseNotificationChannel,
|
BaseNotificationChannel,
|
||||||
parseUserSelectionConfig,
|
parseUserSelectionConfig,
|
||||||
|
SendFnType,
|
||||||
} from './server';
|
} from './server';
|
||||||
|
Loading…
x
Reference in New Issue
Block a user