refactor: pull up message feed

This commit is contained in:
Tristan Yang
2023-01-13 21:08:16 +08:00
parent 6ecffe557b
commit d6847c9968
9 changed files with 136 additions and 96 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "vocechat-web", "name": "vocechat-web",
"version": "0.3.33", "version": "0.3.34",
"private": true, "private": true,
"homepage": "https://voce.chat", "homepage": "https://voce.chat",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -70,7 +70,7 @@ export const PAYMENT_URL_PREFIX =
process.env.NODE_ENV === "production" process.env.NODE_ENV === "production"
? `https://vera.nicegoodthings.com` ? `https://vera.nicegoodthings.com`
: `http://localhost:4000`; : `http://localhost:4000`;
export const CACHE_VERSION = `0.3.32`; export const CACHE_VERSION = `0.3.33`;
export const GuestRoutes = ["/", "/chat", "/chat/channel/:channel_id"]; export const GuestRoutes = ["/", "/chat", "/chat/channel/:channel_id"];
export const ContentTypes = { export const ContentTypes = {
text: "text/plain", text: "text/plain",
+1 -1
View File
@@ -51,7 +51,7 @@ const StyledButton = styled.button`
} }
&.border_less { &.border_less {
box-shadow: none; box-shadow: none;
border: none; border: none !important;
} }
&.cancel { &.cancel {
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
+96 -59
View File
@@ -2,6 +2,7 @@ import { useEffect, useState, useRef } from "react";
import { useLazyGetHistoryMessagesQuery } from "../../app/services/channel"; import { useLazyGetHistoryMessagesQuery } from "../../app/services/channel";
import { useLazyGetHistoryMessagesQuery as useLazyGetDMHistoryMsg } from "../../app/services/user"; import { useLazyGetHistoryMessagesQuery as useLazyGetDMHistoryMsg } from "../../app/services/user";
import { useAppSelector } from "../../app/store"; import { useAppSelector } from "../../app/store";
import { isElementVisible } from "../utils";
export interface PageInfo { export interface PageInfo {
isFirst: boolean; isFirst: boolean;
isLast: boolean; isLast: boolean;
@@ -14,8 +15,9 @@ interface Config extends Partial<PageInfo> {
mids: number[]; mids: number[];
} }
const getFeedWithPagination = (config: Config): PageInfo => { const getFeedWithPagination = (config: Config): PageInfo => {
const { pageNumber = 1, pageSize = 40, mids = [], isLast = false } = config || {}; const { pageNumber = 1, pageSize = 50, mids = [], isLast = false } = config || {};
const shadowMids = mids.slice(0); const shadowMids = mids.slice(0);
console.log("pagination", config, shadowMids);
if (shadowMids.length == 0) if (shadowMids.length == 0)
return { return {
@@ -58,6 +60,7 @@ export default function useMessageFeed({ context = "channel", id }: Props) {
const listRef = useRef<number[]>([]); const listRef = useRef<number[]>([]);
const pageRef = useRef<PageInfo | null>(null); const pageRef = useRef<PageInfo | null>(null);
const containerRef = useRef<HTMLElement | null>(null); const containerRef = useRef<HTMLElement | null>(null);
const [pulling, setPulling] = useState(false);
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
const [appends, setAppends] = useState<number[]>([]); const [appends, setAppends] = useState<number[]>([]);
const [items, setItems] = useState<number[]>([]); const [items, setItems] = useState<number[]>([]);
@@ -78,77 +81,88 @@ export default function useMessageFeed({ context = "channel", id }: Props) {
setItems([]); setItems([]);
setHasMore(true); setHasMore(true);
setAppends([]); setAppends([]);
setPulling(false);
}, [context, id]); }, [context, id]);
useEffect(() => { useEffect(() => {
if (items.length) { // 处理自动滚动
containerRef.current = document.querySelector(`#VOCECHAT_FEED_${context}_${id}`); if (items.length > 0) {
if (containerRef.current) { let wrapper = containerRef.current = document.querySelector(`#VOCECHAT_FEED_${context}_${id}`);
const newScroll = containerRef.current.scrollHeight - containerRef.current.clientHeight; if (wrapper) {
containerRef.current.scrollTop = curScrollPos + (newScroll - oldScroll); const newScroll = wrapper.scrollHeight - wrapper.clientHeight;
wrapper.scrollTop = curScrollPos + (newScroll - oldScroll);
} }
} }
}, [items, context, id]); }, [items, context, id]);
useEffect(() => { useEffect(() => {
//过滤掉本地消息 //处理追加:来自于其它人的实时消息以及自己发的
const serverMids = mids.filter((id: number) => { const [lastMid] = listRef.current.slice(-1);
const ts = +new Date(); const sorteds = mids.slice(0).sort((a: number, b: number) => {
return Math.abs(ts - id) > 10 * 1000; return Number(a) - Number(b);
}); });
if (listRef.current.length == 0 && serverMids.length > 0) { const appends = sorteds.filter((s: number) => s > lastMid);
// 初次 if (appends.length) {
const pageInfo = getFeedWithPagination({ setAppends(appends);
mids: serverMids, const [newestMsgId] = appends.slice(-1);
isLast: true // 自己发的消息:自动往上滚动
}); const container = containerRef.current;
pageRef.current = pageInfo; if (container) {
listRef.current = pageInfo.ids; const msgFromSelf = loginUid == messageData[newestMsgId]?.from_uid;
setItems(listRef.current); const scrollDistance =
// console.log("message pageInfo", serverMids, pageInfo); container.scrollHeight - (container.offsetHeight + container.scrollTop);
} else { // console.log("scrollDistance", msgFromSelf, scrollDistance);
// 追加 if (msgFromSelf) {
const [lastMid] = listRef.current.slice(-1); container.scrollTop = container.scrollHeight;
const sorteds = mids.slice(0).sort((a: number, b: number) => { } else if (scrollDistance <= 100) {
return Number(a) - Number(b); setTimeout(() => {
});
const appends = sorteds.filter((s: number) => s > lastMid);
if (appends.length) {
setAppends(appends);
const [newestMsgId] = appends.slice(-1);
// 自己发的消息
const container = containerRef.current;
if (container) {
const msgFromSelf = loginUid == messageData[newestMsgId]?.from_uid;
const scrollDistance =
container.scrollHeight - (container.offsetHeight + container.scrollTop);
// console.log("scrollDistance", msgFromSelf, scrollDistance);
if (msgFromSelf) {
container.scrollTop = container.scrollHeight; container.scrollTop = container.scrollHeight;
} else if (scrollDistance <= 100) { }, 100);
setTimeout(() => {
container.scrollTop = container.scrollHeight;
}, 100);
}
} }
} }
} }
}, [mids, messageData, loginUid]); }, [mids, messageData, loginUid]);
const pullUp = async () => {
const currPageInfo = pageRef.current; useEffect(() => {
// 第一页 const currentItems = listRef.current;
if (currPageInfo && currPageInfo.isFirst) { // const [lastMid=Infinity]=currentItems.slice(-1)
const [firstMid] = currPageInfo.ids; //过滤掉本地(以及后来追加的消息?)
const { data: newList } = await loadMoreMsgsFromServer({ const serverMids = mids.filter((id: number) => {
mid: firstMid, // 如果是本地消息,id是时间戳
id const ts = +new Date();
}); return Math.abs(ts - id) > 10 * 1000;
if (newList?.length == 0) { });
setHasMore(false); if (serverMids.length > 0) {
return; if (currentItems.length == 0) {
//初次
const pageInfo = getFeedWithPagination({
mids: serverMids,
isLast: true
});
pageRef.current = pageInfo;
listRef.current = pageInfo.ids;
setItems(pageInfo.ids);
// console.log("message pageInfo", serverMids, pageInfo);
} else {
const container = containerRef.current;
if (container) {
const loadMoreEle = container.querySelector("[data-load-more]");
if (isElementVisible(loadMoreEle)) {
// 有更新:来自于拉取历史消息,拉取下一页数据
console.log("effected by pull server data");
loadMore();
}
}
} }
} }
}, [mids]);
const loadMore = () => {
console.log("load more start", mids, listRef.current, pageRef.current);
const currPageInfo = pageRef.current;
let pageInfo: PageInfo; let pageInfo: PageInfo;
if (!currPageInfo) { if (!currPageInfo) {
// 初始化 // 初始化
console.log("first pagination");
pageInfo = getFeedWithPagination({ pageInfo = getFeedWithPagination({
mids, mids,
isLast: true isLast: true
@@ -157,27 +171,50 @@ export default function useMessageFeed({ context = "channel", id }: Props) {
const prevPageNumber = currPageInfo.pageNumber - 1; const prevPageNumber = currPageInfo.pageNumber - 1;
pageInfo = getFeedWithPagination({ pageInfo = getFeedWithPagination({
mids, mids,
pageNumber: prevPageNumber pageNumber: prevPageNumber == 0 ? 1 : prevPageNumber
}); });
console.log("continue to next page", currPageInfo, prevPageNumber, pageInfo);
} }
pageRef.current = pageInfo; pageRef.current = pageInfo;
listRef.current = [...pageInfo.ids, ...listRef.current]; listRef.current = [...new Set([...pageInfo.ids, ...listRef.current])].sort((a, b) => a > b ? 1 : -1);
setTimeout( setTimeout(
() => { () => {
console.log("load more timeout", currPageInfo, mids, listRef.current);
setItems(listRef.current); setItems(listRef.current);
console.log("pull up timeout", currPageInfo, listRef.current);
setHasMore(pageInfo.pageNumber !== 1);
const container = containerRef.current; const container = containerRef.current;
if (container) { if (container) {
curScrollPos = container.scrollTop; curScrollPos = container.scrollTop;
oldScroll = container.scrollHeight - container.clientHeight; oldScroll = container.scrollHeight - container.clientHeight;
} }
setPulling(false);
}, },
currPageInfo?.isLast ? 10 : 500 currPageInfo?.isLast ? 10 : 500
); );
setPulling(false);
};
const pullUp = async () => {
setPulling(true);
const currPageInfo = pageRef.current;
console.log("pull up start", currPageInfo);
// 本地数据的第一页
if (currPageInfo && currPageInfo.isFirst || (!currPageInfo && mids.length == 0)) {
const [firstMid] = currPageInfo ? currPageInfo.ids : [0];
const { data: newList } = await loadMoreMsgsFromServer({
mid: firstMid,
id
});
if (newList?.length == 0) {
// 只有在这里,才可以把load more去掉
setHasMore(false);
}
return;
}
loadMore();
}; };
return { return {
pulling,
mids, mids,
appends, appends,
hasMore, hasMore,
+19
View File
@@ -27,7 +27,26 @@ export const isTreatAsImage = (file: File) => {
} }
return false; return false;
}; };
export const isElementVisible = (el: Element | null) => {
if (!el) return false;
const rect = el.getBoundingClientRect(),
vWidth = window.innerWidth || document.documentElement.clientWidth,
vHeight = window.innerHeight || document.documentElement.clientHeight,
efp = function (x: number, y: number) { return document.elementFromPoint(x, y); };
// Return false if it's not in the viewport
if (rect.right < 0 || rect.bottom < 0
|| rect.left > vWidth || rect.top > vHeight)
return false;
// Return true if any of its four corners are visible
return (
el.contains(efp(rect.left, rect.top))
|| el.contains(efp(rect.right, rect.top))
|| el.contains(efp(rect.right, rect.bottom))
|| el.contains(efp(rect.left, rect.bottom))
);
};
export function getDefaultSize(size?: { width: number; height: number }, min = 480) { export function getDefaultSize(size?: { width: number; height: number }, min = 480) {
if (!size) return { width: 0, height: 0 }; if (!size) return { width: 0, height: 0 };
const { width: oWidth, height: oHeight } = size; const { width: oWidth, height: oHeight } = size;
+2 -1
View File
@@ -35,6 +35,7 @@ function ChannelChat({ cid = 0, dropFiles = [] }: Props) {
const { t } = useTranslation("chat"); const { t } = useTranslation("chat");
const { values: agoraConfig } = useConfig("agora"); const { values: agoraConfig } = useConfig("agora");
const { const {
pulling,
list: msgIds, list: msgIds,
appends, appends,
hasMore, hasMore,
@@ -175,7 +176,7 @@ function ChannelChat({ cid = 0, dropFiles = [] }: Props) {
> >
<StyledChannelChat id={`VOCECHAT_FEED_channel_${cid}`}> <StyledChannelChat id={`VOCECHAT_FEED_channel_${cid}`}>
{hasMore ? ( {hasMore ? (
<LoadMore pullUp={pullUp} /> <LoadMore pullUp={pullUp} pulling={pulling} />
) : ( ) : (
<div className="info"> <div className="info">
<h2 className="title">{t("welcome_channel", { name })}</h2> <h2 className="title">{t("welcome_channel", { name })}</h2>
+2 -1
View File
@@ -18,6 +18,7 @@ type Props = {
}; };
const DMChat: FC<Props> = ({ uid = 0, dropFiles }) => { const DMChat: FC<Props> = ({ uid = 0, dropFiles }) => {
const { const {
pulling,
list: msgIds, list: msgIds,
appends, appends,
hasMore, hasMore,
@@ -70,7 +71,7 @@ const DMChat: FC<Props> = ({ uid = 0, dropFiles }) => {
} }
> >
<StyledDMChat id={`VOCECHAT_FEED_user_${uid}`}> <StyledDMChat id={`VOCECHAT_FEED_user_${uid}`}>
{hasMore ? <LoadMore pullUp={pullUp} /> : null} {hasMore ? <LoadMore pullUp={pullUp} pulling={pulling} /> : null}
{[...feeds].map((mid, idx) => { {[...feeds].map((mid, idx) => {
const curr = messageData[mid]; const curr = messageData[mid];
const prev = idx == 0 ? null : messageData[feeds[idx - 1]]; const prev = idx == 0 ? null : messageData[feeds[idx - 1]];
+2 -2
View File
@@ -26,9 +26,9 @@ const DMList: FC<Props> = ({ uids, setDropFiles }) => {
const sessions = uids.map((uid) => { const sessions = uids.map((uid) => {
const mids = userMessage[uid] || []; const mids = userMessage[uid] || [];
if (mids.length == 0) { if (mids.length == 0) {
return { lastMid: 0, unreads: 0, uid }; return { lastMid: null, unreads: 0, uid };
} }
const lastMid = [...mids].pop() || 0; const lastMid = [...mids].pop();
const readIndex = readUsers[uid]; const readIndex = readUsers[uid];
const { unreads = 0 } = getUnreadCount({ const { unreads = 0 } = getUnreadCount({
mids, mids,
+12 -30
View File
@@ -1,42 +1,24 @@
import { useRef, useEffect, FC } from "react"; import { memo, useEffect, FC } from "react";
import { Waveform } from "@uiball/loaders"; import { Waveform } from "@uiball/loaders";
import { useInViewRef } from "rooks";
type Props = { type Props = {
pulling?: boolean;
pullUp: () => Promise<void> | null; pullUp: () => Promise<void> | null;
}; };
const LoadMore: FC<Props> = ({ pullUp = null }) => { const LoadMore: FC<Props> = ({ pullUp = null, pulling }) => {
const ref = useRef<HTMLDivElement | null>(null); const [myRef, inView] = useInViewRef();
useEffect(() => { useEffect(() => {
const observer = new IntersectionObserver( if (inView && pullUp && !pulling) {
(entries) => { pullUp();
entries.forEach((entry) => {
const intersecting = entry.isIntersecting;
// const currEle = entry.target;
if (intersecting && pullUp) {
// load more
pullUp();
}
});
},
{ threshold: 0 }
);
let currEle: HTMLDivElement | null = null;
if (ref) {
currEle = ref.current;
if (currEle) {
observer.observe(currEle);
}
} }
return () => { }, [inView, pullUp, pulling]);
if (currEle) {
observer.unobserve(currEle);
}
};
}, [ref, pullUp]);
return ( return (
<div className="mt-2 flex justify-center items-center w-full py-2" ref={ref}> <div data-load-more className="mt-2 flex justify-center items-center w-full py-2" ref={myRef}>
<Waveform size={18} lineWeight={4} speed={1} color="#ccc" /> <Waveform size={18} lineWeight={4} speed={1} color="#ccc" />
</div> </div>
); );
}; };
export default LoadMore; export default memo(LoadMore, (prev, next) => {
return prev.pulling === next.pulling;
});