refactor: virtual message feed
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import { Waveform } from '@uiball/loaders';
|
||||
import clsx from 'clsx';
|
||||
// import React from 'react'
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import { useAppSelector } from '../../../../app/store';
|
||||
import EditIcon from "../../../../assets/icons/edit.svg";
|
||||
|
||||
type ChannelHeaderProps = {
|
||||
cid: number
|
||||
}
|
||||
const ChannelHeader = ({ cid }: ChannelHeaderProps) => {
|
||||
const { pathname } = useLocation();
|
||||
const { t } = useTranslation("chat");
|
||||
const { data, loginUser } = useAppSelector(store => {
|
||||
return {
|
||||
loginUser: store.authData.user,
|
||||
data: store.channels.byId[cid]
|
||||
|
||||
};
|
||||
});
|
||||
return (
|
||||
<div className="pt-14 px-1 md:px-0 flex flex-col items-start gap-2">
|
||||
<h2 className="font-bold text-4xl dark:text-white">{t("welcome_channel", { name: data?.name })}</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300">{t("welcome_desc", { name: data?.name })} </p>
|
||||
{loginUser?.is_admin && (
|
||||
<NavLink to={`/setting/channel/${cid}/overview?f=${pathname}`} className="flex items-center gap-1 bg-clip-text text-fill-transparent bg-gradient-to-r from-blue-500 to-primary-400 ">
|
||||
<EditIcon className="w-4 h-4 fill-blue-500" />
|
||||
{t("edit_channel")}
|
||||
</NavLink>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type Props = {
|
||||
context?: {
|
||||
id: number,
|
||||
isChannel: boolean,
|
||||
loadingMore: boolean
|
||||
}
|
||||
}
|
||||
const CustomHeader = ({ context }: Props) => {
|
||||
if (!context) return null;
|
||||
const { id, isChannel, loadingMore } = context;
|
||||
return <>
|
||||
{isChannel ? <ChannelHeader cid={id} /> : null}
|
||||
<div className={clsx("mt-2 w-full py-2 ", loadingMore ? "flex-center" : "hidden")} >
|
||||
<Waveform size={18} lineWeight={4} speed={1} color="#ccc" />
|
||||
</div>
|
||||
</>;
|
||||
};
|
||||
|
||||
export default CustomHeader;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
// @ts-ignore
|
||||
const CustomList = forwardRef(({ style, ...props }, ref) => {
|
||||
// @ts-ignore
|
||||
return <div style={{ ...style, width: `calc(100% - 2rem)` }} {...props} ref={ref} />;
|
||||
});
|
||||
|
||||
|
||||
export default CustomList;
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useEffect, useRef, useCallback, useState, useMemo } from 'react';
|
||||
// import clsx from 'clsx';
|
||||
// import { useTranslation } from 'react-i18next';
|
||||
import { useDebounce, useLocalstorageState } from 'rooks';
|
||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||
|
||||
import { useLazyLoadMoreMessagesQuery, useReadMessageMutation } from '../../../../app/services/message';
|
||||
import { useAppSelector } from '../../../../app/store';
|
||||
import { renderMessageFragment } from '../../utils';
|
||||
import NewMessageBottomTip from "../NewMessageBottomTip";
|
||||
import CustomList from './CustomList';
|
||||
import CustomHeader from './CustomHeader';
|
||||
type Props = {
|
||||
context: "user" | "channel",
|
||||
id: number
|
||||
}
|
||||
// const firstMsgIndex = 10000;
|
||||
// let prevMids: number[] = [];
|
||||
const VirtualMessageFeed = ({ context, id }: Props) => {
|
||||
// const { t } = useTranslation("chat");
|
||||
// const [firstItemIndex, setFirstItemIndex] = useState(firstMsgIndex);
|
||||
const [atBottom, setAtBottom] = useState(false);
|
||||
const [loadMoreMessage, { isLoading: loadingMore, isSuccess, data: historyData }] = useLazyLoadMoreMessagesQuery();
|
||||
const [historyMid, setHistoryMid] = useLocalstorageState<'reached' | string>(`history_mid_${context}_${id}`, "");
|
||||
const vList = useRef<VirtuosoHandle | null>(null);
|
||||
const [updateReadIndex] = useReadMessageMutation();
|
||||
const updateReadDebounced = useDebounce(updateReadIndex, 300);
|
||||
const {
|
||||
mids = [],
|
||||
selects,
|
||||
messageData,
|
||||
loginUser,
|
||||
footprint
|
||||
} = useAppSelector((store) => {
|
||||
return {
|
||||
mids: context == "user" ? store.userMessage.byId[id] : store.channelMessage[id],
|
||||
selects: store.ui.selectMessages[`${context}_${id}`],
|
||||
footprint: store.footprint,
|
||||
loginUser: store.authData.user,
|
||||
messageData: store.message || {}
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isSuccess && historyData) {
|
||||
if (historyData.length == 0) {
|
||||
// 到顶了
|
||||
setHistoryMid(`reached`);
|
||||
} else {
|
||||
// 记录最新的mid
|
||||
const [{ mid }] = historyData;
|
||||
setHistoryMid(`${mid}`);
|
||||
}
|
||||
}
|
||||
}, [isSuccess, historyData, mids]);
|
||||
// useEffect(() => {
|
||||
// console.log("diff mids", prevMids, mids);
|
||||
// const newCount = mids.length - prevMids.length;
|
||||
// setFirstItemIndex((prev) => prev - newCount);
|
||||
// }, [mids]);
|
||||
|
||||
// 加载更多
|
||||
const handleLoadMore = useCallback(() => {
|
||||
console.log("reach start ");
|
||||
if (historyMid === "reached") return;
|
||||
let lastMid = mids.slice(0, 1)[0];
|
||||
if (historyMid) {
|
||||
lastMid = +historyMid;
|
||||
}
|
||||
// prevMids = mids;
|
||||
loadMoreMessage({ context, id, mid: lastMid });
|
||||
// return false;
|
||||
}, [mids, context, id]);
|
||||
// 自动跟随
|
||||
const handleFollowOutput = (isAtBottom: boolean) => {
|
||||
const [lastMid] = mids ? mids.slice(-1) : [0];
|
||||
const ts = new Date().getTime();
|
||||
// tricky
|
||||
const isSentByMyself = ts - lastMid < 1000;
|
||||
if (isAtBottom || isSentByMyself) {
|
||||
return isAtBottom ? "smooth" : true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
// 滚动到底部
|
||||
const handleScrollBottom = useCallback(() => {
|
||||
const vl = vList!.current;
|
||||
if (vl) {
|
||||
vl.scrollToIndex(mids.length - 1);
|
||||
}
|
||||
}, [mids]);
|
||||
const handleBottomStateChange = (bottom: boolean) => {
|
||||
setAtBottom(bottom);
|
||||
};
|
||||
const readIndex = context == "channel" ? footprint.readChannels[id] : footprint.readUsers[id];
|
||||
return <>
|
||||
<Virtuoso
|
||||
// logLevel={LogLevel.DEBUG}
|
||||
overscan={50}
|
||||
context={{ loadingMore, id, isChannel: context == "channel" }}
|
||||
id={`VOCECHAT_FEED_${context}_${id}`}
|
||||
className='px-1 md:px-4 py-4.5 overflow-x-hidden overflow-y-scroll'
|
||||
ref={vList}
|
||||
components={{
|
||||
List: CustomList,
|
||||
Header: CustomHeader,
|
||||
}}
|
||||
// firstItemIndex={firstItemIndex}
|
||||
initialTopMostItemIndex={mids.length - 1}
|
||||
startReached={handleLoadMore}
|
||||
data={mids}
|
||||
atTopThreshold={context == "channel" ? 160 : 0}
|
||||
atBottomStateChange={handleBottomStateChange}
|
||||
atBottomThreshold={400}
|
||||
followOutput={handleFollowOutput}
|
||||
itemContent={(idx, mid) => {
|
||||
// 计算出真正的index
|
||||
// const idx = index - firstItemIndex;
|
||||
const curr = messageData[mid];
|
||||
if (!curr) return null;
|
||||
const isFirst = idx == 0;
|
||||
const prev = isFirst ? null : messageData[mids[idx - 1]];
|
||||
const read = curr?.from_uid == loginUser?.uid || mid <= readIndex;
|
||||
return renderMessageFragment({
|
||||
selectMode: !!selects,
|
||||
updateReadIndex: updateReadDebounced,
|
||||
read,
|
||||
prev,
|
||||
curr,
|
||||
contextId: id,
|
||||
context
|
||||
});
|
||||
}} />
|
||||
{!atBottom && <NewMessageBottomTip context={context} id={id} scrollToBottom={handleScrollBottom} />}
|
||||
</>
|
||||
;
|
||||
};
|
||||
|
||||
export default VirtualMessageFeed;
|
||||
Reference in New Issue
Block a user