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:
Sheldon Guo 2024-11-06 09:29:05 +08:00 committed by GitHub
parent a6f16acf1c
commit ee512d7c9d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 736 additions and 81 deletions

View File

@ -182,7 +182,12 @@ export class OptionsParser {
sortField.push(direction);
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);
}

View File

@ -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",

View File

@ -37,6 +37,7 @@ const InternalItem: React.FC<TabBarItemProps> = () => {
height: 100%;
top: 0;
left: 0;
padding-top: 5px;
`,
)}
>

View File

@ -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",

View File

@ -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

View File

@ -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;
}

View File

@ -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]);

View File

@ -8,5 +8,6 @@
*/
export * from './MobilePageContent';
export * from './MobilePageContentContainer';
export * from './initializer';
export * from './schema';

View File

@ -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"
}
}

View File

@ -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'.",
),
},
},
},
},

View File

@ -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;

View File

@ -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}
/>

View File

@ -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

View File

@ -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".',
),
},
},
},
},

View File

@ -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;

View File

@ -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' });

View File

@ -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' });

View File

@ -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;
}

View File

@ -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' });

View File

@ -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',
});

View File

@ -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();
},
};
},
};

View File

@ -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,
});
}
}
}

View File

@ -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();

View File

@ -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;
};

View File

@ -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."
}

View File

@ -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() {

View File

@ -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.": "站内信页面已创建。"
}

View File

@ -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: {

View File

@ -13,4 +13,5 @@ export {
ChannelsCollectionDefinition,
BaseNotificationChannel,
parseUserSelectionConfig,
SendFnType,
} from './server';