refactor: virtual message feed

This commit is contained in:
Tristan Yang
2023-03-14 08:32:26 +08:00
parent 0bf167e4e8
commit 184191643a
11 changed files with 245 additions and 455 deletions
-185
View File
@@ -1,185 +0,0 @@
import { memo, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { NavLink, useLocation } from 'react-router-dom';
import { useDebounce, useLocalstorageState } from 'rooks';
import { ViewportList, ViewportListRef } from 'react-viewport-list';
import { Waveform } from '@uiball/loaders';
import clsx from 'clsx';
import { useLazyLoadMoreMessagesQuery, useReadMessageMutation } from '../../../app/services/message';
import { useAppSelector } from '../../../app/store';
import EditIcon from "../../../assets/icons/edit.svg";
import { renderMessageFragment } from '../utils';
import { KEY_UID } from '../../../app/config';
// import LoadMore from '../LoadMore';
const checkHistory = (key: string) => {
const currUid = localStorage.getItem(KEY_UID) ?? "";
const _key = `${currUid}_${key}`;
const local = Number(localStorage.getItem(_key) ?? 0);
return local == 0;
};
const setHistory = (key: string) => {
const currUid = localStorage.getItem(KEY_UID) ?? "";
const _key = `${currUid}_${key}`;
localStorage.setItem(_key, "1");
};
type Props = {
context: "user" | "channel",
id: number
}
const triggerScrollHeight = 400;
const MessageFeed = ({ context, id }: Props) => {
const { t } = useTranslation("chat");
const [loadMoreMessage, { isLoading: loadingMore, isSuccess, data: historyData }] = useLazyLoadMoreMessagesQuery();
const [historyMid, setHistoryMid] = useLocalstorageState(`history_mid_${context}_${id}`, "");
const listRef = useRef<ViewportListRef | null>(null);
const ref = useRef<HTMLDivElement | null>(
null,
);
const { pathname } = useLocation();
const [updateReadIndex] = useReadMessageMutation();
const updateReadDebounced = useDebounce(updateReadIndex, 300);
const {
mids,
selects,
data,
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,
data: context == "channel" ? store.channels.byId[id] : undefined,
messageData: store.message || {}
};
});
const debouncedScrollHandler = useDebounce(() => {
const container = ref ? ref.current : null;
if (container && historyMid && !loadingMore) {
if (container.scrollTop <= 200) {
// pull up
console.log("start pul up");
loadMoreMessage({ context, id, mid: +historyMid });
}
}
}, 500);
useEffect(() => {
if (isSuccess && historyData) {
if (historyData.length == 0) {
// 到顶了
setHistory(`history_${context}_${id}`);
} else {
// 记录最新的mid
const [{ mid }] = historyData;
setHistoryMid(String(mid));
}
}
}, [isSuccess, historyData, context, id]);
useEffect(() => {
// context changed, scroll to bottom
console.log("listRef", listRef);
if (listRef && listRef.current) {
const list = listRef.current;
list.scrollToIndex({
prerender: 40,
index: Number.POSITIVE_INFINITY
});
}
const hasHistory = checkHistory(`history_${context}_${id}`);
const container = ref ? ref.current : null;
if (hasHistory && container) {
container.addEventListener("scroll", debouncedScrollHandler);
}
return () => {
if (hasHistory && container) {
container.removeEventListener("scroll", debouncedScrollHandler);
}
};
}, [context, id]);
useEffect(() => {
// check current scroll position first, scroll to bottom only when under the trigger number
if (ref && ref.current) {
const container = ref.current;
const { scrollHeight, scrollTop, offsetHeight } = container;
const deltaNum = scrollHeight - scrollTop - offsetHeight;
console.log("delta", deltaNum);
// sent by myself (tricky!)
const [lastMid] = mids ? mids.slice(-1) : [0];
const ts = new Date().getTime();
const isSentByMyself = ts - lastMid < 1000;
if (deltaNum < triggerScrollHeight) {
container.classList.add("scroll-smooth");
// scroll to bottom
container.scrollTop = container.scrollHeight;
container.classList.remove("scroll-smooth");
} else if (isSentByMyself) {
container.scrollTop = container.scrollHeight;
}
}
// 首次进入,就用mids
const lastMid = mids ? mids.slice(0, 1)[0] : "";
if (!historyMid && lastMid) {
setHistoryMid(`${lastMid}`);
}
}, [mids, historyMid]);
// const handleMessageListChange = (data: [number, number]) => {
// const [first, last] = data;
// // todo
// // console.log("index changed", data);
// };
const readIndex = context == "channel" ? footprint.readChannels[id] : footprint.readUsers[id];
const isEmptyList = !mids || mids.length == 0;
return (
<article ref={ref} id={`VOCECHAT_FEED_${context}_${id}`} className="w-full h-full px-1 md:px-4 py-4.5 overflow-x-hidden overflow-y-scroll will-change-contents">
{context == "channel" && <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/${id}/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>}
<div className={clsx("mt-2 w-full py-2", loadingMore ? "flex-center" : "hidden")} >
<Waveform size={18} lineWeight={4} speed={1} color="#ccc" />
</div>
{isEmptyList ? null : <ViewportList
// onViewportIndexesChange={handleMessageListChange}
overscan={10}
// itemSize={100}
initialPrerender={40}
scrollThreshold={2000}
ref={listRef}
viewportRef={ref}
items={mids}
>
{((mid, idx) => {
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
});
})}
</ViewportList>}
</article>
);
};
export default memo(MessageFeed);
+11 -29
View File
@@ -1,17 +1,16 @@
import clsx from 'clsx';
import { useState, useEffect } from 'react';
// import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useAppSelector } from '../../../app/store';
import getUnreadCount from '../utils';
import { triggerScrollHeight } from './MessageFeed';
type Props = {
context: "channel" | "user",
id: number
id: number,
scrollToBottom?: () => void
}
// linear-gradient(135deg,_#3C8CE7_0%,_#00EAFF_100%)
const NewMessageBottomTip = ({ context, id }: Props) => {
const [visible, setVisible] = useState(false);
const NewMessageBottomTip = ({ context, id, scrollToBottom }: Props) => {
const { t } = useTranslation("chat");
const {
readIndex,
@@ -34,33 +33,16 @@ const NewMessageBottomTip = ({ context, id }: Props) => {
messageData,
loginUid
});
useEffect(() => {
const container = document.querySelector(`#VOCECHAT_FEED_${context}_${id}`) as HTMLElement;
if (container) {
const { scrollHeight, scrollTop, offsetHeight } = container;
const deltaNum = scrollHeight - scrollTop - offsetHeight;
const showTheTip = deltaNum > triggerScrollHeight && unreads > 0;
console.log("show the tip", showTheTip);
setVisible(showTheTip);
}
}, [context, id, unreads]);
const handleMarkRead = () => {
const container = document.querySelector(`#VOCECHAT_FEED_${context}_${id}`) as HTMLElement;
if (container) {
// scroll to bottom
container.classList.add("scroll-smooth");
container.scrollTop = container.scrollHeight;
container.classList.remove("scroll-smooth");
setVisible(false);
}
};
console.log("unreads", unreads);
return (
<aside className={clsx(`absolute -top-2 right-4 -translate-y-full
<aside className={clsx(`z-[999] absolute bottom-20 right-4
justify-between text-xs
rounded-full py-1 px-3 text-white z-10
rounded-full py-1 px-3 text-white
bg-gradient-to-tl from-[#3C8CE7] to-[#00EAFF]`,
visible ? "flex" : "hidden")}>
<button onClick={handleMarkRead}>{t("new_msg", { num: unreads })}</button>
unreads > 0 ? "flex" : "hidden")}>
<button onClick={scrollToBottom}>{t("new_msg", { num: unreads })}</button>
<span className='absolute -left-1 -translate-x-full'>👇</span>
</aside>
);
@@ -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;
+3 -2
View File
@@ -15,7 +15,8 @@ import LicenseUpgradeTip from "./LicenseOutdatedTip";
import DnDTip from "./DnDTip";
import IconWarning from '../../../assets/icons/warning.svg';
import ImagePreview from "../../../common/component/ImagePreview";
import MessageFeed from "./MessageFeed";
import VirtualMessageFeed from "./VirtualMessageFeed";
interface Props {
readonly?: boolean;
@@ -96,7 +97,7 @@ const Layout: FC<Props> = ({
<div className="w-full h-full flex items-start justify-between relative" >
<div className="rounded-br-2xl flex flex-col absolute bottom-0 w-full h-full" ref={messagesContainer}>
{/* 消息流 */}
<MessageFeed context={context} id={to} />
<VirtualMessageFeed key={`${context}_${to}`} context={context} id={to} />
{/* 发送框 */}
<div className={`px-2 py-0 md:p-4 ${selects ? "selecting" : ""}`}>
{readonly ? (