diff --git a/shared/index.tsx b/shared/index.tsx index c978bb76..f273115b 100644 --- a/shared/index.tsx +++ b/shared/index.tsx @@ -106,6 +106,8 @@ export { useGroupInfo, useGroupPanel, useIsGroupOwner, + useGroupUnread, + useGroupTextPanelUnread, } from './redux/hooks/useGroup'; export { useUserInfo, useUserId } from './redux/hooks/useUserInfo'; export { userActions, groupActions } from './redux/slices'; diff --git a/shared/redux/hooks/useGroup.ts b/shared/redux/hooks/useGroup.ts index 5d72da5e..63592eff 100644 --- a/shared/redux/hooks/useGroup.ts +++ b/shared/redux/hooks/useGroup.ts @@ -1,7 +1,8 @@ import { useMemo } from 'react'; -import type { GroupInfo, GroupPanel } from '../../model/group'; +import { GroupInfo, GroupPanel, GroupPanelType } from '../../model/group'; import { isValidStr } from '../../utils/string-helper'; import { useAppSelector } from './useAppSelector'; +import { useUnread } from './useUnread'; import { useUserId } from './useUserInfo'; /** @@ -41,3 +42,28 @@ export function useIsGroupOwner(groupId: string, userId?: string): boolean { return typeof selfUserId === 'string' && groupInfo?.owner === selfUserId; } } + +/** + * 检查群组是否有未读消息 + * @param groupId 群组id + */ +export function useGroupUnread(groupId: string): boolean { + const group = useGroupInfo(groupId); + const groupTextPanelIds = (group?.panels ?? []) + .filter((panel) => panel.type === GroupPanelType.TEXT) + .map((p) => p.id); + + const unread = useUnread(groupTextPanelIds); + + return unread.some((u) => u === true); +} + +/** + * 检查群组聊天面板是否有未读消息 + * @param textPanelId 文字面板id + */ +export function useGroupTextPanelUnread(textPanelId: string): boolean { + const unread = useUnread([textPanelId]); + + return unread[0]; +} diff --git a/shared/redux/hooks/useUnread.ts b/shared/redux/hooks/useUnread.ts new file mode 100644 index 00000000..7d6d9159 --- /dev/null +++ b/shared/redux/hooks/useUnread.ts @@ -0,0 +1,23 @@ +import { useAppSelector } from './useAppSelector'; + +/** + * 返回某些会话是否有未读信息 + */ +export function useUnread(converseIds: string[]) { + const ack = useAppSelector((state) => state.chat.ack); + const lastMessageMap = useAppSelector((state) => state.chat.lastMessageMap); + + return converseIds.map((converseId) => { + if ( + ack[converseId] === undefined && + lastMessageMap[converseId] !== undefined + ) { + // 没有已读记录且远程有数据 + return true; + } + + // 当远端最后一条消息的id > 本地已读状态的最后一条消息id, + // 则返回true(有未读消息) + return lastMessageMap[converseId] > ack[converseId]; + }); +} diff --git a/web/src/components/ChatBox/ChatMessageList/index.tsx b/web/src/components/ChatBox/ChatMessageList/index.tsx index 70c5850b..cc948029 100644 --- a/web/src/components/ChatBox/ChatMessageList/index.tsx +++ b/web/src/components/ChatBox/ChatMessageList/index.tsx @@ -43,6 +43,10 @@ export const ChatMessageList = React.forwardRef< const onUpdateReadedMessageRef = useUpdateRef(props.onUpdateReadedMessage); useEffect(() => { + if (props.messages.length === 0) { + return; + } + if (containerRef.current?.scrollTop === 0) { // 当前列表在最低 onUpdateReadedMessageRef.current( @@ -52,6 +56,10 @@ export const ChatMessageList = React.forwardRef< }, [props.messages.length]); const handleScroll = useCallback(() => { + if (props.messages.length === 0) { + return; + } + if (containerRef.current?.scrollTop === 0) { onUpdateReadedMessageRef.current( props.messages[props.messages.length - 1]._id diff --git a/web/src/components/ChatBox/useMessageAck.ts b/web/src/components/ChatBox/useMessageAck.ts index 5c4927bc..7789ebde 100644 --- a/web/src/components/ChatBox/useMessageAck.ts +++ b/web/src/components/ChatBox/useMessageAck.ts @@ -31,7 +31,7 @@ export function useMessageAck(converseId: string, messages: ChatMessage[]) { lastMessageIdRef.current = lastMessageId; }, 1000, - { leading: false, trailing: true } + { leading: true, trailing: true } ), [] ); diff --git a/web/src/routes/Main/Content/Group/Sidebar.tsx b/web/src/routes/Main/Content/Group/Sidebar.tsx index f298a2c2..670d7c61 100644 --- a/web/src/routes/Main/Content/Group/Sidebar.tsx +++ b/web/src/routes/Main/Content/Group/Sidebar.tsx @@ -1,9 +1,15 @@ import React from 'react'; -import { GroupPanelType, isValidStr, useGroupInfo } from 'tailchat-shared'; +import { + GroupPanel, + GroupPanelType, + isValidStr, + useGroupInfo, +} from 'tailchat-shared'; import { useParams } from 'react-router'; import { GroupHeader } from './GroupHeader'; import { GroupSection } from '@/components/GroupSection'; import { GroupPanelItem } from '@/components/GroupPanelItem'; +import { GroupTextPanelItem } from './TextPanelItem'; interface GroupParams { groupId: string; @@ -17,6 +23,17 @@ export const Sidebar: React.FC = React.memo(() => { const groupInfo = useGroupInfo(groupId); const groupPanels = groupInfo?.panels ?? []; + const renderItem = (panel: GroupPanel) => + panel.type === GroupPanelType.TEXT ? ( + + ) : ( + #} + to={`/main/group/${groupId}/${panel.id}`} + /> + ); + return (
@@ -30,22 +47,11 @@ export const Sidebar: React.FC = React.memo(() => { {groupPanels .filter((sub) => sub.parentId === panel.id) .map((sub) => ( -
- #
} - to={`/main/group/${groupId}/${sub.id}`} - /> -
+
{renderItem(sub)}
))} ) : ( - #} - to={`/main/group/${groupId}/${panel.id}`} - /> +
{renderItem(panel)}
) )} diff --git a/web/src/routes/Main/Content/Group/TextPanelItem.tsx b/web/src/routes/Main/Content/Group/TextPanelItem.tsx new file mode 100644 index 00000000..274464a7 --- /dev/null +++ b/web/src/routes/Main/Content/Group/TextPanelItem.tsx @@ -0,0 +1,25 @@ +import { GroupPanelItem } from '@/components/GroupPanelItem'; +import React from 'react'; +import { GroupPanel, useGroupTextPanelUnread } from 'tailchat-shared'; + +interface GroupTextPanelItemProps { + groupId: string; + panel: GroupPanel; +} +export const GroupTextPanelItem: React.FC = React.memo( + (props) => { + const { groupId, panel } = props; + const panelId = panel.id; + const hasUnread = useGroupTextPanelUnread(panelId); + + return ( + #} + to={`/main/group/${groupId}/${panel.id}`} + badge={hasUnread} + /> + ); + } +); +GroupTextPanelItem.displayName = 'GroupTextPanelItem';