mirror of
https://gitee.com/nocobase/nocobase.git
synced 2025-07-01 18:52: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,8 +182,13 @@ export class OptionsParser {
|
||||
|
||||
sortField.push(direction);
|
||||
if (this.database.isMySQLCompatibleDialect()) {
|
||||
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);
|
||||
}
|
||||
|
||||
|
@ -18,7 +18,7 @@
|
||||
"@types/react-dom": "17.x",
|
||||
"ahooks": "3.x",
|
||||
"antd": "5.x",
|
||||
"antd-mobile": "^5.29.1",
|
||||
"antd-mobile": "^5.38",
|
||||
"antd-style": "3.x",
|
||||
"classnames": "2.x",
|
||||
"react": "18.x",
|
||||
|
@ -37,6 +37,7 @@ const InternalItem: React.FC<TabBarItemProps> = () => {
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
padding-top: 5px;
|
||||
`,
|
||||
)}
|
||||
>
|
||||
|
@ -22,7 +22,7 @@
|
||||
"@formily/shared": "2.x",
|
||||
"@types/react": "17.x",
|
||||
"@types/react-dom": "17.x",
|
||||
"antd-mobile": "^5.36.1",
|
||||
"antd-mobile": "^5.38",
|
||||
"re-resizable": "6.6.0",
|
||||
"react-device-detect": "2.2.3",
|
||||
"@emotion/css": "11.x",
|
||||
|
@ -47,7 +47,7 @@ export const MobileTabBarItem: FC<MobileTabBarItemProps> = (props) => {
|
||||
})}
|
||||
style={{ lineHeight: 1 }}
|
||||
>
|
||||
<Badge content={badge}>
|
||||
<Badge content={badge} style={{ '--top': '5px' }}>
|
||||
<span className={'adm-tab-bar-item-icon'}>{icon}</span>
|
||||
</Badge>
|
||||
<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 { merge } from '@nocobase/utils/client';
|
||||
import { ISchema } from '@nocobase/client';
|
||||
import { MobileRouteItem } from '../../../mobile-providers';
|
||||
|
||||
export function getMobileTabBarItemSchema(routeItem: MobileRouteItem) {
|
||||
return {
|
||||
const _schema = {
|
||||
name: routeItem.id,
|
||||
type: 'void',
|
||||
'x-decorator': 'BlockItem',
|
||||
@ -18,5 +29,6 @@ export function getMobileTabBarItemSchema(routeItem: MobileRouteItem) {
|
||||
schemaUid: routeItem.schemaUid,
|
||||
...(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'> {
|
||||
schemaUid: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export const MobileTabBarPage: FC<MobileTabBarPageProps> = (props) => {
|
||||
const { schemaUid, ...rests } = props;
|
||||
const navigate = useNavigate();
|
||||
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(() => {
|
||||
navigate(url);
|
||||
}, [url, navigate]);
|
||||
|
@ -8,5 +8,6 @@
|
||||
*/
|
||||
|
||||
export * from './MobilePageContent';
|
||||
export * from './MobilePageContentContainer';
|
||||
export * from './initializer';
|
||||
export * from './schema';
|
||||
|
@ -12,6 +12,9 @@
|
||||
"dependencies": {
|
||||
"immer": "^10.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"antd-mobile": "^5.38"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@formily/reactive": "^2",
|
||||
"@formily/reactive-react": "^2",
|
||||
@ -20,6 +23,5 @@
|
||||
"@nocobase/server": "1.x",
|
||||
"@nocobase/test": "1.x",
|
||||
"react-router-dom": "^6.x"
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -51,7 +51,7 @@ export const ContentConfigForm = ({ variableOptions }) => {
|
||||
url: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
title: `{{t("Detail URL")}}`,
|
||||
title: `{{t("PC detail URL")}}`,
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Variable.TextArea',
|
||||
'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".',
|
||||
),
|
||||
},
|
||||
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 { 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 { createStyles } from 'antd-style';
|
||||
import { Icon } from '@nocobase/client';
|
||||
@ -32,6 +33,9 @@ import {
|
||||
startMsgSSEStreamWithRetry,
|
||||
inboxVisible,
|
||||
userIdObs,
|
||||
liveSSEObs,
|
||||
messageMapObs,
|
||||
selectedChannelNameObs,
|
||||
} from '../observables';
|
||||
const useStyles = createStyles(({ token }) => {
|
||||
return {
|
||||
@ -61,7 +65,28 @@ const InnerInbox = (props) => {
|
||||
}, []);
|
||||
|
||||
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 CloseIcon = (
|
||||
@ -69,6 +94,41 @@ const InnerInbox = (props) => {
|
||||
<CloseOutlined />
|
||||
</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 (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
@ -79,7 +139,7 @@ const InnerInbox = (props) => {
|
||||
<Badge count={unreadMsgsCountObs.value} size="small" offset={[-12, 14]}>
|
||||
<Button
|
||||
className={styles.button}
|
||||
title={'Apps'}
|
||||
title={t('Message')}
|
||||
icon={<Icon type={'MailOutlined'} />}
|
||||
onClick={onIconClick}
|
||||
/>
|
||||
|
@ -26,7 +26,8 @@ import {
|
||||
ChannelStatus,
|
||||
} from '../observables';
|
||||
|
||||
import { MessageList } from './MessageList';
|
||||
import MessageList from './MessageList';
|
||||
import FilterTab from './FilterTab';
|
||||
|
||||
const InnerInboxContent = () => {
|
||||
const { token } = theme.useToken();
|
||||
@ -61,34 +62,6 @@ const InnerInboxContent = () => {
|
||||
</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
|
||||
|
@ -100,7 +100,7 @@ export const MessageConfigForm = ({ variableOptions }) => {
|
||||
url: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
title: `{{t("Detail URL")}}`,
|
||||
title: `{{t("PC detail URL")}}`,
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Variable.TextArea',
|
||||
'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".',
|
||||
),
|
||||
},
|
||||
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,
|
||||
} from '../observables';
|
||||
|
||||
export const MessageList = observer(() => {
|
||||
const MessageList = observer(() => {
|
||||
const { t } = useLocalTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { token } = theme.useToken();
|
||||
@ -172,3 +172,5 @@ export const MessageList = observer(() => {
|
||||
</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 { MessageManagerProvider } from './MessageManagerProvider';
|
||||
import NotificationManager from '@nocobase/plugin-notification-manager/client';
|
||||
import MobileManager from '@nocobase/plugin-mobile/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';
|
||||
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 {
|
||||
async afterAdd() {}
|
||||
|
||||
@ -38,6 +43,26 @@ export class PluginNotificationInAppClient extends Plugin {
|
||||
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;
|
||||
}) as { value: boolean };
|
||||
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) => {
|
||||
const apiClient = getAPIClient();
|
||||
|
@ -6,18 +6,15 @@
|
||||
* 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 { observable, reaction } from '@formily/reactive';
|
||||
import { SSEData } from '../../types';
|
||||
import { messageMapObs, updateUnreadMsgsCount } from './message';
|
||||
import { channelMapObs, fetchChannels, selectedChannelNameObs } from './channel';
|
||||
import { inboxVisible } from './inbox';
|
||||
import { fetchChannels } from './channel';
|
||||
import { getAPIClient } from '../utils';
|
||||
import { uid } from '@nocobase/utils/client';
|
||||
|
||||
export const liveSSEObs = observable<{ value: SSEData | null }>({ value: null });
|
||||
|
||||
reaction(
|
||||
() => liveSSEObs.value,
|
||||
(sseData) => {
|
||||
@ -26,41 +23,27 @@ reaction(
|
||||
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 () => {
|
||||
export const startMsgSSEStreamWithRetry: () => () => void = () => {
|
||||
let retryTimes = 0;
|
||||
const controller = new AbortController();
|
||||
let disposed = false;
|
||||
const dispose = () => {
|
||||
disposed = true;
|
||||
controller.abort();
|
||||
};
|
||||
|
||||
const clientId = uid();
|
||||
const createMsgSSEConnection = async (clientId: string) => {
|
||||
const apiClient = getAPIClient();
|
||||
const res = await apiClient.silent().request({
|
||||
url: 'myInAppMessages:sse',
|
||||
method: 'get',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Accept: 'text/event-stream',
|
||||
},
|
||||
@ -93,10 +76,11 @@ export const startMsgSSEStreamWithRetry = async () => {
|
||||
const nextDelay = retryTimes < 6 ? 1000 * Math.pow(2, retryTimes) : 60000;
|
||||
retryTimes++;
|
||||
setTimeout(() => {
|
||||
connectWithRetry();
|
||||
if (!disposed) connectWithRetry();
|
||||
}, nextDelay);
|
||||
return { error };
|
||||
}
|
||||
};
|
||||
connectWithRetry();
|
||||
return dispose;
|
||||
};
|
@ -1,7 +1,11 @@
|
||||
{
|
||||
"Inbox": "Inbox",
|
||||
"Message": "Message",
|
||||
"Loading": "Loading",
|
||||
"Loading more": "Loading more",
|
||||
"No more": "No more",
|
||||
"Loading failed,": "Loading failed,",
|
||||
"please reload": "please reload",
|
||||
"Detail": "Detail",
|
||||
"Content": "Content",
|
||||
"Datetime": "Datetime",
|
||||
@ -15,8 +19,10 @@
|
||||
"Message group name": "Message group name",
|
||||
"Message title": "Message title",
|
||||
"Message content": "Message content",
|
||||
"Inapp Message": "Inapp Message",
|
||||
"Detail URL": "Detail URL",
|
||||
"PC detail URL": "PC 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\".",
|
||||
"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) {
|
||||
return `{{t('${key}', { ns: '${NAMESPACE}', nsMode: 'fallback' })}}`;
|
||||
return `{{t('${key}', { ns: ['${NAMESPACE}', 'client'], nsMode: 'fallback' })}}`;
|
||||
}
|
||||
|
||||
export function useLocalTranslation() {
|
||||
|
@ -1,7 +1,11 @@
|
||||
{
|
||||
"Inbox": "收信箱",
|
||||
"Message": "消息",
|
||||
"Loading": "加载中",
|
||||
"Loading more": "加载更多",
|
||||
"No more": "没有更多了",
|
||||
"Loading failed,": "加载失败,",
|
||||
"please reload": "重新加载",
|
||||
"Detail": "详情",
|
||||
"Content": "内容",
|
||||
"Datetime": "时间",
|
||||
@ -14,8 +18,11 @@
|
||||
"Message group name": "消息分组名称",
|
||||
"Message title": "消息标题",
|
||||
"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”。",
|
||||
"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
|
||||
where: {
|
||||
[Op.and]: [userFilter, latestMsgReceiveTSFilter, channelIdFilter, channelStatusFilter].filter(Boolean),
|
||||
},
|
||||
sort: ['-latestMsgReceiveTimestamp'],
|
||||
});
|
||||
|
||||
const countRes = channelsRepo.count({
|
||||
//@ts-ignore
|
||||
where: {
|
||||
|
@ -13,4 +13,5 @@ export {
|
||||
ChannelsCollectionDefinition,
|
||||
BaseNotificationChannel,
|
||||
parseUserSelectionConfig,
|
||||
SendFnType,
|
||||
} from './server';
|
||||
|
Loading…
x
Reference in New Issue
Block a user