refactor: load more in msg feed

This commit is contained in:
Tristan Yang
2023-03-09 11:32:44 +08:00
parent 384de7c8df
commit 3af4cab880
8 changed files with 171 additions and 181 deletions
+1 -18
View File
@@ -8,10 +8,9 @@ import { removeMessage } from "../slices/message";
import { removeChannelSession } from "../slices/message.channel";
import { removeReactionMessage } from "../slices/message.reaction";
import { onMessageSendStarted } from "./handlers";
import handleChatMessage from "../../common/hook/useStreaming/chat.handler";
import { Channel, ChannelDTO, CreateChannelDTO } from "../../types/channel";
import { RootState } from "../store";
import { ContentTypeKey, ChatMessage } from "../../types/message";
import { ContentTypeKey } from "../../types/message";
export const channelApi = createApi({
reducerPath: "channelApi",
@@ -76,21 +75,6 @@ export const channelApi = createApi({
}
}
}),
getHistoryMessages: builder.query<ChatMessage[], { id: number; mid?: number; limit?: number }>({
query: ({ id, mid = null, limit = 100 }) => ({
url: mid
? `/group/${id}/history?before=${mid}&limit=${limit}`
: `/group/${id}/history?limit=${limit}`
}),
async onQueryStarted(params, { dispatch, getState, queryFulfilled }) {
const { data: messages } = await queryFulfilled;
if (messages?.length) {
messages.forEach((msg) => {
handleChatMessage(msg, dispatch, getState() as RootState);
});
}
}
}),
createInviteLink: builder.query<string, number | void>({
query: (gid) => ({
headers: {
@@ -211,7 +195,6 @@ export const {
useLazyLeaveChannelQuery,
useLazyCreateInviteLinkQuery,
useCreateInviteLinkQuery,
useLazyGetHistoryMessagesQuery,
useGetChannelQuery,
useUpdateChannelMutation,
useLazyRemoveChannelQuery,
+23 -3
View File
@@ -6,7 +6,8 @@ import { onMessageSendStarted } from "./handlers";
import { normalizeArchiveData } from "../../common/utils";
import baseQuery from "./base.query";
import { Archive, FavoriteArchive, OG } from "../../types/resource";
import { ContentTypeKey, UploadFileResponse } from "../../types/message";
import { ChatMessage, ContentTypeKey, UploadFileResponse } from "../../types/message";
import handleChatMessage from "../../common/hook/useStreaming/chat.handler";
import { RootState } from "../store";
export const messageApi = createApi({
@@ -147,7 +148,25 @@ export const messageApi = createApi({
}
}
}),
loadMoreMessages: builder.query<ChatMessage[], { context?: "channel" | "user", id: number; mid?: number; limit?: number }>({
query: ({ context = "channel", id, mid = "", limit = 100 }) => {
const url = context == "channel" ?
`/group/${id}/history?limit=${limit}${mid ? `&before=${mid}` : ""}`
:
`/user/${id}/history?limit=${limit}${mid ? `&before=${mid}` : ""}`;
return {
url
};
},
async onQueryStarted(params, { dispatch, getState, queryFulfilled }) {
const { data: messages } = await queryFulfilled;
if (messages?.length) {
messages.forEach((msg) => {
handleChatMessage(msg, dispatch, getState() as RootState);
});
}
}
}),
replyWithChatGPT: builder.mutation<{ message: string, prompt: string }, string>({
query: (prompt) => ({
url: `https://official.voce.chat/chatgpt/complete`,
@@ -218,5 +237,6 @@ export const {
useLazyDeleteMessageQuery,
useReadMessageMutation,
useCreateArchiveMutation,
useReplyWithChatGPTMutation
useReplyWithChatGPTMutation,
useLazyLoadMoreMessagesQuery
} = messageApi;
+1 -19
View File
@@ -5,10 +5,8 @@ import { updateAutoDeleteSetting, updateMute } from "../slices/footprint";
import { fillUsers } from "../slices/users";
import BASE_URL, { ContentTypes } from "../config";
import { onMessageSendStarted } from "./handlers";
import handleChatMessage from "../../common/hook/useStreaming/chat.handler";
import { AutoDeleteMsgDTO, BotAPIKey, User, UserCreateDTO, UserDTO, UserForAdmin, UserForAdminDTO } from "../../types/user";
import { ChatMessage, ContentTypeKey, MuteDTO } from "../../types/message";
import { RootState } from "../store";
import { ContentTypeKey, MuteDTO } from "../../types/message";
export const userApi = createApi({
reducerPath: "userApi",
@@ -157,21 +155,6 @@ export const userApi = createApi({
await onMessageSendStarted.call(this, param1, param2, "user");
}
}),
getHistoryMessages: builder.query<ChatMessage[], { id: number; mid?: number; limit?: number }>({
query: ({ id, mid = null, limit = 100 }) => ({
url: mid
? `/user/${id}/history?before=${mid}&limit=${limit}`
: `/user/${id}/history?limit=${limit}`
}),
async onQueryStarted(params, { dispatch, getState, queryFulfilled }) {
const { data: messages } = await queryFulfilled;
if (messages?.length) {
messages.forEach((msg) => {
handleChatMessage(msg, dispatch, getState() as RootState);
});
}
}
}),
})
});
@@ -181,7 +164,6 @@ export const {
useUpdateAvatarByAdminMutation,
useUpdateAutoDeleteMsgMutation,
useCreateUserMutation,
useLazyGetHistoryMessagesQuery,
useUpdateUserMutation,
useUpdateMuteSettingMutation,
useLazyDeleteUserQuery,
+2 -2
View File
@@ -6,13 +6,13 @@ import { useLazyGetUsersQuery } from "../../app/services/user";
import { useLazyGetServerQuery } from "../../app/services/server";
import useStreaming from "./useStreaming";
import { useAppSelector } from "../../app/store";
import { useLazyGetHistoryMessagesQuery } from "../../app/services/channel";
import { useLazyLoadMoreMessagesQuery } from "../../app/services/message";
// type Props={
// guest?:boolean
// }
let preloadChannelMsgs = false;
export default function usePreload() {
const [preloadChannelMessages] = useLazyGetHistoryMessagesQuery();
const [preloadChannelMessages] = useLazyLoadMoreMessagesQuery();
const { rehydrate, rehydrated } = useRehydrate();
const {
loginUid,
+58 -63
View File
@@ -54,69 +54,64 @@ function ChannelChat({ cid = 0, dropFiles = [] }: Props) {
const addVisible = loginUser?.is_admin || owner == loginUser?.uid;
const pinCount = data?.pinned_messages?.length || 0;
const toolClass = `relative cursor-pointer`;
return (
<>
<Layout
to={cid}
context="channel"
dropFiles={dropFiles}
aside={
<ul className="flex flex-col gap-6">
<Tooltip tip={t("pin")} placement="left">
<Tippy
placement="left-start"
popperOptions={{ strategy: "fixed" }}
offset={[0, 150]}
interactive
trigger="click"
content={<PinList id={cid} />}
>
<li className={`${toolClass}`}>
{pinCount > 0 ? <span className="absolute -top-2 -right-2 flex-center w-4 h-4 rounded-full bg-primary-400 text-white font-bold text-[10px]">{pinCount}</span> : null}
<IconPin className="fill-gray-500" />
</li>
</Tippy>
</Tooltip>
<Tooltip tip={t("fav")} placement="left">
<Tippy
placement="left-start"
popperOptions={{ strategy: "fixed" }}
offset={[0, 164]}
interactive
trigger="click"
content={<FavList cid={cid} />}
>
<li className={`${toolClass}`}>
<IconFav className="fill-gray-500" />
</li>
</Tippy>
</Tooltip>
<li
className={`${toolClass}`}
onClick={toggleMembersVisible}
>
<Tooltip tip={t("channel_members")} placement="left">
<IconPeople className={membersVisible ? "fill-gray-600" : ""} />
</Tooltip>
return <Layout
to={cid}
context="channel"
dropFiles={dropFiles}
aside={
<ul className="flex flex-col gap-6">
<Tooltip tip={t("pin")} placement="left">
<Tippy
placement="left-start"
popperOptions={{ strategy: "fixed" }}
offset={[0, 150]}
interactive
trigger="click"
content={<PinList id={cid} />}
>
<li className={`${toolClass}`}>
{pinCount > 0 ? <span className="absolute -top-2 -right-2 flex-center w-4 h-4 rounded-full bg-primary-400 text-white font-bold text-[10px]">{pinCount}</span> : null}
<IconPin className="fill-gray-500" />
</li>
</ul>
}
header={
<header className="px-5 py-4 flex items-center justify-center md:justify-between shadow-[inset_0_-1px_0_rgb(0_0_0_/_10%)]">
<GoBackNav />
<div className="flex items-center gap-1">
<ChannelIcon personal={!is_public} />
<span className="text-gray-800 dark:text-white">{name}</span>
<span className="ml-2 text-gray-500 hidden md:block">{description}</span>
</div>
</header>
}
users={
<Members uids={memberIds} addVisible={addVisible} cid={cid} ownerId={owner} membersVisible={membersVisible} />
}
/>
</>
);
</Tippy>
</Tooltip>
<Tooltip tip={t("fav")} placement="left">
<Tippy
placement="left-start"
popperOptions={{ strategy: "fixed" }}
offset={[0, 164]}
interactive
trigger="click"
content={<FavList cid={cid} />}
>
<li className={`${toolClass}`}>
<IconFav className="fill-gray-500" />
</li>
</Tippy>
</Tooltip>
<li
className={`${toolClass}`}
onClick={toggleMembersVisible}
>
<Tooltip tip={t("channel_members")} placement="left">
<IconPeople className={membersVisible ? "fill-gray-600" : ""} />
</Tooltip>
</li>
</ul>
}
header={
<header className="px-5 py-4 flex items-center justify-center md:justify-between shadow-[inset_0_-1px_0_rgb(0_0_0_/_10%)]">
<GoBackNav />
<div className="flex items-center gap-1">
<ChannelIcon personal={!is_public} />
<span className="text-gray-800 dark:text-white">{name}</span>
<span className="ml-2 text-gray-500 hidden md:block">{description}</span>
</div>
</header>
}
users={
<Members uids={memberIds} addVisible={addVisible} cid={cid} ownerId={owner} membersVisible={membersVisible} />
}
/>;
}
export default memo(ChannelChat, (prev, next) => prev.cid == next.cid);
+4 -46
View File
@@ -1,11 +1,8 @@
// import { useState, useEffect } from "react";
// import { NavLink } from "react-router-dom";
import { useTranslation } from "react-i18next";
import useMessageFeed from "../../../common/hook/useMessageFeed";
// import { useTranslation } from "react-i18next";
import ChannelIcon from "../../../common/component/ChannelIcon";
import Layout from "../Layout";
import { renderMessageFragment } from "../utils";
import LoadMore from "../LoadMore";
import { useAppSelector } from "../../../app/store";
import GoBackNav from "../../../common/component/GoBackNav";
@@ -13,27 +10,14 @@ type Props = {
cid?: number;
};
export default function GuestChannelChat({ cid = 0 }: Props) {
const { t } = useTranslation("chat");
const {
list: msgIds,
appends,
hasMore,
pullUp,
pulling
} = useMessageFeed({
context: "channel",
id: cid
});
const { data, messageData } = useAppSelector((store) => {
// const { t } = useTranslation("chat");
const { data } = useAppSelector((store) => {
return {
footprint: store.footprint,
data: store.channels.byId[cid],
messageData: store.message || {}
};
});
if (!data) return null;
const { name, description, is_public } = data;
const feeds = [...msgIds, ...appends];
return (
<Layout
readonly
@@ -49,32 +33,6 @@ export default function GuestChannelChat({ cid = 0 }: Props) {
</div>
</header>
}
>
<>
{hasMore ? (
<LoadMore pullUp={pullUp} pulling={pulling} />
) : (
<div className="pt-14 flex flex-col items-start gap-2">
<h2 className="font-bold text-4xl dark:text-white">{t("welcome_channel", { name })}</h2>
<p className="text-gray-600 dark:text-gray-300">{t("welcome_desc", { name })} </p>
</div>
)}
{/* <div className="feed"> */}
{feeds.map((mid, idx) => {
const curr = messageData[mid];
if (!curr) return null;
const isFirst = idx == 0;
const prev = isFirst ? null : messageData[feeds[idx - 1]];
return renderMessageFragment({
readonly: true,
selectMode: false,
prev,
curr,
contextId: cid,
context: "channel"
});
})}
</>
</Layout>
/>
);
}
+79 -18
View File
@@ -1,26 +1,43 @@
import { memo, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { NavLink, useLocation } from 'react-router-dom';
import { useDebounce } from 'rooks';
import { useDebounce, useLocalstorageState } from 'rooks';
import { ViewportList, ViewportListRef } from 'react-viewport-list';
import { Waveform } from '@uiball/loaders';
import clsx from 'clsx';
import { useReadMessageMutation } from '../../../app/services/message';
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 { t } = useTranslation("chat");
const { pathname } = useLocation();
const [updateReadIndex] = useReadMessageMutation();
const updateReadDebounced = useDebounce(updateReadIndex, 300);
@@ -41,21 +58,49 @@ const MessageFeed = ({ context, id }: Props) => {
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;
ref.current?.classList.remove("scroll-smooth");
list.scrollToIndex({
prerender: 40,
index: Number.POSITIVE_INFINITY
});
setTimeout(() => {
// tricky
ref.current?.classList.add("scroll-smooth");
}, 150);
}
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
@@ -64,22 +109,35 @@ const MessageFeed = ({ context, id }: Props) => {
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]);
// 首次进入,就用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;
console.log("index changed", data);
};
const readIndex = context == "channel" ? footprint.readChannels[id] : Number.POSITIVE_INFINITY;
// 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 scroll-smooth will-change-contents">
<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>
@@ -90,8 +148,11 @@ const MessageFeed = ({ context, id }: Props) => {
</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}
// onViewportIndexesChange={handleMessageListChange}
overscan={10}
// itemSize={100}
initialPrerender={40}
+3 -12
View File
@@ -1,20 +1,11 @@
import { useEffect, FC } from "react";
import { FC } from "react";
import { Waveform } from "@uiball/loaders";
import { useInViewRef } from "rooks";
type Props = {
pulling?: boolean;
pullUp: () => Promise<void> | null;
};
const LoadMore: FC<Props> = ({ pullUp = null, pulling }) => {
const [myRef, inView] = useInViewRef();
useEffect(() => {
if (inView && pullUp && !pulling) {
pullUp();
}
}, [inView, pullUp, pulling]);
const LoadMore: FC<Props> = () => {
return (
<div data-load-more className="mt-2 flex-center w-full py-2" ref={myRef}>
<div data-load-more className="mt-2 flex-center w-full py-2" >
<Waveform size={18} lineWeight={4} speed={1} color="#ccc" />
</div>
);