refactor: virtual message feed
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import localforage from "localforage";
|
||||||
import clearTable from "./clear.handler";
|
import clearTable from "./clear.handler";
|
||||||
interface Params {
|
interface Params {
|
||||||
payload: any;
|
payload: any;
|
||||||
@@ -5,7 +6,7 @@ interface Params {
|
|||||||
operation: string;
|
operation: string;
|
||||||
}
|
}
|
||||||
export default async function handler({ operation, data = {}, payload }: Params) {
|
export default async function handler({ operation, data = {}, payload }: Params) {
|
||||||
const table = window.CACHE["footprint"];
|
const table = window.CACHE["footprint"] as typeof localforage;;
|
||||||
if (operation.startsWith("reset")) {
|
if (operation.startsWith("reset")) {
|
||||||
clearTable("footprint");
|
clearTable("footprint");
|
||||||
return;
|
return;
|
||||||
@@ -20,7 +21,14 @@ export default async function handler({ operation, data = {}, payload }: Params)
|
|||||||
case "updateAfterMid":
|
case "updateAfterMid":
|
||||||
{
|
{
|
||||||
const afterMid = payload;
|
const afterMid = payload;
|
||||||
await table?.setItem("afterMid", afterMid);
|
// console.log("local after mid", afterMid, data);
|
||||||
|
table.getItem("afterMid").then((val) => {
|
||||||
|
const storedNum = Number(val ?? 0);
|
||||||
|
if (storedNum < afterMid) {
|
||||||
|
table?.setItem("afterMid", afterMid);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "updateMute":
|
case "updateMute":
|
||||||
|
|||||||
@@ -160,9 +160,10 @@ export const messageApi = createApi({
|
|||||||
},
|
},
|
||||||
async onQueryStarted(params, { dispatch, getState, queryFulfilled }) {
|
async onQueryStarted(params, { dispatch, getState, queryFulfilled }) {
|
||||||
const { data: messages } = await queryFulfilled;
|
const { data: messages } = await queryFulfilled;
|
||||||
|
const fromHistory = true;
|
||||||
if (messages?.length) {
|
if (messages?.length) {
|
||||||
messages.forEach((msg) => {
|
messages.forEach((msg) => {
|
||||||
handleChatMessage(msg, dispatch, getState() as RootState);
|
handleChatMessage(msg, dispatch, getState() as RootState, fromHistory);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,11 @@ const footprintSlice = createSlice({
|
|||||||
state.usersVersion = action.payload;
|
state.usersVersion = action.payload;
|
||||||
},
|
},
|
||||||
updateAfterMid(state, action: PayloadAction<number>) {
|
updateAfterMid(state, action: PayloadAction<number>) {
|
||||||
state.afterMid = action.payload;
|
const newMid = action.payload;
|
||||||
|
// 如果新mid小于已有的afterMid,则不必更新
|
||||||
|
if (state.afterMid < newMid) {
|
||||||
|
state.afterMid = action.payload;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
updateAutoDeleteSetting(state, action: PayloadAction<AutoDeleteMessageSettingDTO>) {
|
updateAutoDeleteSetting(state, action: PayloadAction<AutoDeleteMessageSettingDTO>) {
|
||||||
const payload = action.payload;
|
const payload = action.payload;
|
||||||
|
|||||||
@@ -1,228 +0,0 @@
|
|||||||
import { useEffect, useState, useRef, useCallback } from "react";
|
|
||||||
import { useLazyGetHistoryMessagesQuery } from "../../app/services/channel";
|
|
||||||
import { useLazyGetHistoryMessagesQuery as useLazyGetDMHistoryMsg } from "../../app/services/user";
|
|
||||||
import { useAppSelector } from "../../app/store";
|
|
||||||
import { isElementVisible } from "../utils";
|
|
||||||
export interface PageInfo {
|
|
||||||
isFirst: boolean;
|
|
||||||
isLast: boolean;
|
|
||||||
pageCount: number;
|
|
||||||
pageSize: number;
|
|
||||||
pageNumber: number;
|
|
||||||
ids: number[];
|
|
||||||
}
|
|
||||||
interface Config extends Partial<PageInfo> {
|
|
||||||
mids: number[];
|
|
||||||
}
|
|
||||||
const getFeedWithPagination = (config: Config): PageInfo => {
|
|
||||||
const { pageNumber = 1, pageSize = 50, mids = [], isLast = false } = config || {};
|
|
||||||
const shadowMids = mids.slice(0);
|
|
||||||
console.log("pagination", config, shadowMids);
|
|
||||||
|
|
||||||
if (shadowMids.length == 0)
|
|
||||||
return {
|
|
||||||
isFirst: true,
|
|
||||||
isLast: true,
|
|
||||||
pageCount: 0,
|
|
||||||
pageSize,
|
|
||||||
pageNumber: 1,
|
|
||||||
ids: []
|
|
||||||
};
|
|
||||||
shadowMids.sort((a, b) => {
|
|
||||||
return Number(a) - Number(b);
|
|
||||||
});
|
|
||||||
// console.log("message pagination", shadowMids);
|
|
||||||
const pageCount = Math.ceil(shadowMids.length / pageSize);
|
|
||||||
const computedPageNumber = isLast ? pageCount : pageNumber;
|
|
||||||
const _start = -(pageCount - computedPageNumber + 1) * pageSize;
|
|
||||||
const _tmp = _start + pageSize;
|
|
||||||
const _end = _tmp == 0 ? undefined : _tmp;
|
|
||||||
const ids = shadowMids.slice(_start, _end);
|
|
||||||
// console.log("start,end", _start, _end, ids);
|
|
||||||
return {
|
|
||||||
isFirst: computedPageNumber == 1,
|
|
||||||
isLast: computedPageNumber == pageCount,
|
|
||||||
pageCount,
|
|
||||||
pageSize,
|
|
||||||
pageNumber: computedPageNumber,
|
|
||||||
ids
|
|
||||||
};
|
|
||||||
};
|
|
||||||
let curScrollPos = 0;
|
|
||||||
let oldScroll = 0;
|
|
||||||
type Props = {
|
|
||||||
context?: "channel" | "user";
|
|
||||||
id: number;
|
|
||||||
};
|
|
||||||
export default function useMessageFeed({ context = "channel", id }: Props) {
|
|
||||||
const [loadMoreChannelMsgs] = useLazyGetHistoryMessagesQuery();
|
|
||||||
const [loadMoreDmMsgs] = useLazyGetDMHistoryMsg();
|
|
||||||
const listRef = useRef<number[]>([]);
|
|
||||||
const pageRef = useRef<PageInfo | null>(null);
|
|
||||||
const containerRef = useRef<HTMLElement | null>(null);
|
|
||||||
const [pulling, setPulling] = useState(false);
|
|
||||||
const [hasMore, setHasMore] = useState(true);
|
|
||||||
const [appends, setAppends] = useState<number[]>([]);
|
|
||||||
const [items, setItems] = useState<number[]>([]);
|
|
||||||
const loadMoreMsgsFromServer = context == "channel" ? loadMoreChannelMsgs : loadMoreDmMsgs;
|
|
||||||
const { mids, messageData, loginUid } = useAppSelector((store) => {
|
|
||||||
return {
|
|
||||||
loginUid: store.authData.user?.uid,
|
|
||||||
mids:
|
|
||||||
context == "channel" ? store.channelMessage[id] || [] : store.userMessage.byId[id] || [],
|
|
||||||
messageData: store.message
|
|
||||||
};
|
|
||||||
});
|
|
||||||
useEffect(() => {
|
|
||||||
// curScrollPos = 0;
|
|
||||||
// oldScroll = 0;
|
|
||||||
listRef.current = [];
|
|
||||||
pageRef.current = null;
|
|
||||||
setItems([]);
|
|
||||||
setHasMore(true);
|
|
||||||
setAppends([]);
|
|
||||||
setPulling(false);
|
|
||||||
}, [context, id]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const currentItems = listRef.current;
|
|
||||||
// const [lastMid=Infinity]=currentItems.slice(-1)
|
|
||||||
//过滤掉本地(以及后来追加的消息?)
|
|
||||||
const serverMids = mids.filter((id: number) => {
|
|
||||||
// 如果是本地消息,id是时间戳
|
|
||||||
const ts = +new Date();
|
|
||||||
return Math.abs(ts - id) > 10 * 1000;
|
|
||||||
});
|
|
||||||
if (serverMids.length > 0) {
|
|
||||||
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]");
|
|
||||||
console.log("effected by pull server data", loadMoreEle, isElementVisible(loadMoreEle));
|
|
||||||
if (isElementVisible(loadMoreEle)) {
|
|
||||||
// 有更新:来自于拉取历史消息,拉取下一页数据
|
|
||||||
loadMore();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [mids]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
//处理追加:来自于其它人的实时消息以及自己发的
|
|
||||||
const [lastMid] = listRef.current.slice(-1);
|
|
||||||
const sorteds = mids.slice(0).sort((a: number, b: number) => {
|
|
||||||
return Number(a) - Number(b);
|
|
||||||
});
|
|
||||||
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;
|
|
||||||
} else if (scrollDistance <= 100) {
|
|
||||||
setTimeout(() => {
|
|
||||||
container.scrollTop = container.scrollHeight;
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [mids, messageData, loginUid]);
|
|
||||||
useEffect(() => {
|
|
||||||
// 处理自动滚动
|
|
||||||
if (items.length > 0) {
|
|
||||||
let wrapper = containerRef.current = document.querySelector(`#VOCECHAT_FEED_${context}_${id}`);
|
|
||||||
if (wrapper) {
|
|
||||||
const newScroll = wrapper.scrollHeight - wrapper.clientHeight;
|
|
||||||
wrapper.scrollTop = curScrollPos + (newScroll - oldScroll);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [items, context, id]);
|
|
||||||
|
|
||||||
const loadMore = () => {
|
|
||||||
console.log("load more start", mids, listRef.current, pageRef.current);
|
|
||||||
const currPageInfo = pageRef.current;
|
|
||||||
let pageInfo: PageInfo;
|
|
||||||
if (!currPageInfo) {
|
|
||||||
// 初始化
|
|
||||||
console.log("first pagination");
|
|
||||||
|
|
||||||
pageInfo = getFeedWithPagination({
|
|
||||||
mids,
|
|
||||||
isLast: true
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const prevPageNumber = currPageInfo.pageNumber - 1;
|
|
||||||
pageInfo = getFeedWithPagination({
|
|
||||||
mids,
|
|
||||||
pageNumber: prevPageNumber == 0 ? 1 : prevPageNumber
|
|
||||||
});
|
|
||||||
console.log("continue to next page", currPageInfo, prevPageNumber, pageInfo);
|
|
||||||
}
|
|
||||||
pageRef.current = pageInfo;
|
|
||||||
listRef.current = [...new Set([...pageInfo.ids, ...listRef.current])].sort((a, b) => a > b ? 1 : -1);
|
|
||||||
setTimeout(
|
|
||||||
() => {
|
|
||||||
console.log("load more timeout", currPageInfo, mids, listRef.current);
|
|
||||||
setItems(listRef.current);
|
|
||||||
const container = containerRef.current;
|
|
||||||
if (container) {
|
|
||||||
curScrollPos = container.scrollTop;
|
|
||||||
oldScroll = container.scrollHeight - container.clientHeight;
|
|
||||||
}
|
|
||||||
setPulling(false);
|
|
||||||
},
|
|
||||||
currPageInfo?.isLast ? 10 : 500
|
|
||||||
);
|
|
||||||
setPulling(false);
|
|
||||||
};
|
|
||||||
const pullUp = useCallback(
|
|
||||||
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) {
|
|
||||||
// 只有在这里,才可以把has more去掉
|
|
||||||
setHasMore(false);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
loadMore();
|
|
||||||
},
|
|
||||||
[context, id],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
pulling,
|
|
||||||
mids,
|
|
||||||
appends,
|
|
||||||
hasMore,
|
|
||||||
pullUp,
|
|
||||||
list: items
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -18,7 +18,7 @@ type CurrentState = {
|
|||||||
[key: number]: number;
|
[key: number]: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
const handler = (data: ChatEvent, dispatch: AppDispatch, currState: CurrentState) => {
|
const handler = (data: ChatEvent, dispatch: AppDispatch, currState: CurrentState, fromHistory = false) => {
|
||||||
const {
|
const {
|
||||||
mid,
|
mid,
|
||||||
from_uid,
|
from_uid,
|
||||||
@@ -42,12 +42,15 @@ const handler = (data: ChatEvent, dispatch: AppDispatch, currState: CurrentState
|
|||||||
properties,
|
properties,
|
||||||
expires_in
|
expires_in
|
||||||
};
|
};
|
||||||
switch (type) {
|
if (!fromHistory) {
|
||||||
case "normal":
|
// 如果来自历史消息的拉取,则忽略更新after mid
|
||||||
case "reply":
|
switch (type) {
|
||||||
// 更新after_mid
|
case "normal":
|
||||||
dispatch(updateAfterMid(mid));
|
case "reply":
|
||||||
break;
|
// 更新after_mid
|
||||||
|
dispatch(updateAfterMid(mid));
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const { loginUid, readUsers = {}, readChannels = {} } = currState;
|
const { loginUid, readUsers = {}, readChannels = {} } = currState;
|
||||||
const to = "gid" in target ? "channel" : "user";
|
const to = "gid" in target ? "channel" : "user";
|
||||||
|
|||||||
@@ -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);
|
|
||||||
@@ -1,17 +1,16 @@
|
|||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { useState, useEffect } from 'react';
|
// import { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAppSelector } from '../../../app/store';
|
import { useAppSelector } from '../../../app/store';
|
||||||
import getUnreadCount from '../utils';
|
import getUnreadCount from '../utils';
|
||||||
import { triggerScrollHeight } from './MessageFeed';
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
context: "channel" | "user",
|
context: "channel" | "user",
|
||||||
id: number
|
id: number,
|
||||||
|
scrollToBottom?: () => void
|
||||||
}
|
}
|
||||||
// linear-gradient(135deg,_#3C8CE7_0%,_#00EAFF_100%)
|
// linear-gradient(135deg,_#3C8CE7_0%,_#00EAFF_100%)
|
||||||
const NewMessageBottomTip = ({ context, id }: Props) => {
|
const NewMessageBottomTip = ({ context, id, scrollToBottom }: Props) => {
|
||||||
const [visible, setVisible] = useState(false);
|
|
||||||
const { t } = useTranslation("chat");
|
const { t } = useTranslation("chat");
|
||||||
const {
|
const {
|
||||||
readIndex,
|
readIndex,
|
||||||
@@ -34,33 +33,16 @@ const NewMessageBottomTip = ({ context, id }: Props) => {
|
|||||||
messageData,
|
messageData,
|
||||||
loginUid
|
loginUid
|
||||||
});
|
});
|
||||||
useEffect(() => {
|
console.log("unreads", unreads);
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return (
|
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
|
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]`,
|
bg-gradient-to-tl from-[#3C8CE7] to-[#00EAFF]`,
|
||||||
visible ? "flex" : "hidden")}>
|
unreads > 0 ? "flex" : "hidden")}>
|
||||||
<button onClick={handleMarkRead}>{t("new_msg", { num: unreads })}</button>
|
<button onClick={scrollToBottom}>{t("new_msg", { num: unreads })}</button>
|
||||||
<span className='absolute -left-1 -translate-x-full'>👇</span>
|
<span className='absolute -left-1 -translate-x-full'>👇</span>
|
||||||
</aside>
|
</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;
|
||||||
@@ -15,7 +15,8 @@ import LicenseUpgradeTip from "./LicenseOutdatedTip";
|
|||||||
import DnDTip from "./DnDTip";
|
import DnDTip from "./DnDTip";
|
||||||
import IconWarning from '../../../assets/icons/warning.svg';
|
import IconWarning from '../../../assets/icons/warning.svg';
|
||||||
import ImagePreview from "../../../common/component/ImagePreview";
|
import ImagePreview from "../../../common/component/ImagePreview";
|
||||||
import MessageFeed from "./MessageFeed";
|
|
||||||
|
import VirtualMessageFeed from "./VirtualMessageFeed";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
@@ -96,7 +97,7 @@ const Layout: FC<Props> = ({
|
|||||||
<div className="w-full h-full flex items-start justify-between relative" >
|
<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}>
|
<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" : ""}`}>
|
<div className={`px-2 py-0 md:p-4 ${selects ? "selecting" : ""}`}>
|
||||||
{readonly ? (
|
{readonly ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user