(fix): a bunch or weird scroll behaviour
This commit is contained in:
parent
41517631ee
commit
f3cf836fcd
3 changed files with 320 additions and 398 deletions
|
|
@ -3,6 +3,7 @@ import type { RawMessage } from "../values";
|
||||||
import Text from "@tensamin/markdown/text";
|
import Text from "@tensamin/markdown/text";
|
||||||
import { AlertTriangle } from "lucide-react";
|
import { AlertTriangle } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import type { User } from "@tensamin/user/context";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
|
|
@ -13,21 +14,20 @@ import {
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@tensamin/ui";
|
} from "@tensamin/ui";
|
||||||
import Wrapper from "@tensamin/user/wrapper";
|
|
||||||
import { useChat } from "../context";
|
|
||||||
import MessageContextMenu from "./messageContextMenu";
|
import MessageContextMenu from "./messageContextMenu";
|
||||||
import Media from "./media";
|
import Media from "./media";
|
||||||
|
|
||||||
function MessageComponent({
|
function MessageComponent({
|
||||||
grouped,
|
grouped,
|
||||||
message,
|
message,
|
||||||
|
user,
|
||||||
}: {
|
}: {
|
||||||
grouped: boolean;
|
grouped: boolean;
|
||||||
message: RawMessage & {
|
message: RawMessage & {
|
||||||
failed?: boolean;
|
failed?: boolean;
|
||||||
};
|
};
|
||||||
|
user: User | null;
|
||||||
}) {
|
}) {
|
||||||
const { userId } = useChat();
|
|
||||||
const actuallyFailed = message.failed && message.message_state === "awaiting";
|
const actuallyFailed = message.failed && message.message_state === "awaiting";
|
||||||
|
|
||||||
const [isValidURL, setIsValidURL] = useState(false);
|
const [isValidURL, setIsValidURL] = useState(false);
|
||||||
|
|
@ -60,10 +60,7 @@ function MessageComponent({
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Wrapper
|
{user ? (
|
||||||
loading={"State not achievable"}
|
|
||||||
userId={message.sent_by_self ? "own" : userId}
|
|
||||||
component={(user) => (
|
|
||||||
<>
|
<>
|
||||||
{grouped ? (
|
{grouped ? (
|
||||||
<p className="w-9 text-xs group-hover:visible invisible text-muted-foreground">
|
<p className="w-9 text-xs group-hover:visible invisible text-muted-foreground">
|
||||||
|
|
@ -107,8 +104,9 @@ function MessageComponent({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
) : (
|
||||||
|
"Loading"
|
||||||
)}
|
)}
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</MessageContextMenu>
|
</MessageContextMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -122,6 +120,8 @@ export default React.memo(MessageComponent, (prev, next) => {
|
||||||
prev.message.height === next.message.height &&
|
prev.message.height === next.message.height &&
|
||||||
prev.message.sent_by_self === next.message.sent_by_self &&
|
prev.message.sent_by_self === next.message.sent_by_self &&
|
||||||
prev.message.message_state === next.message.message_state &&
|
prev.message.message_state === next.message.message_state &&
|
||||||
prev.message.failed === next.message.failed
|
prev.message.failed === next.message.failed &&
|
||||||
|
prev.grouped === next.grouped &&
|
||||||
|
prev.user === next.user
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,56 +1,68 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { useChat } from "./context";
|
import { failedUser } from "@tensamin/shared/data";
|
||||||
|
import { useStorage } from "@tensamin/storage/context";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useUser, type User } from "@tensamin/user/context";
|
||||||
|
|
||||||
import InputComponent from "./components/input";
|
import InputComponent from "./components/input";
|
||||||
import Message from "./components/message";
|
import Message from "./components/message";
|
||||||
|
import { useChat } from "./context";
|
||||||
|
import {
|
||||||
|
PAGE_SIZE,
|
||||||
|
FALLBACK_MESSAGE_HEIGHT,
|
||||||
|
MESSAGES_PER_VIRTUAL_ROW,
|
||||||
|
} from "./values";
|
||||||
|
|
||||||
import { PAGE_SIZE } from "./values";
|
function getEstimatedMessageHeight(message: { height?: number }) {
|
||||||
import { useIsMobile } from "@tensamin/ui";
|
return typeof message.height === "number" && Number.isFinite(message.height)
|
||||||
import { Loader2 } from "lucide-react";
|
? Math.max(1, Math.ceil(message.height))
|
||||||
|
: FALLBACK_MESSAGE_HEIGHT;
|
||||||
function getDistanceFromBottom(element: HTMLDivElement) {
|
|
||||||
return element.scrollHeight - (element.scrollTop + element.clientHeight);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const FALLBACK_MESSAGE_HEIGHT = 56;
|
function shouldFetchPreviousPage({
|
||||||
|
entry,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
userScrolledUp,
|
||||||
|
}: {
|
||||||
|
entry: IntersectionObserverEntry | undefined;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
isFetchingNextPage: boolean;
|
||||||
|
userScrolledUp: boolean;
|
||||||
|
}) {
|
||||||
|
return [
|
||||||
|
entry?.isIntersecting === true,
|
||||||
|
userScrolledUp,
|
||||||
|
hasNextPage,
|
||||||
|
!isFetchingNextPage,
|
||||||
|
].every(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the chat screen with virtualized history and live message updates.
|
* Renders the chat screen with virtualized history and live message updates.
|
||||||
* @returns Chat screen JSX.
|
* @returns Chat screen JSX.
|
||||||
*/
|
*/
|
||||||
export default function Screen() {
|
export default function Screen() {
|
||||||
const {
|
const { getMessages, liveMessages, clearLiveMessages, userId, sharedSecret } =
|
||||||
getMessages,
|
useChat();
|
||||||
liveMessages,
|
const { get: getUser } = useUser();
|
||||||
clearLiveMessages,
|
const { load } = useStorage();
|
||||||
userId,
|
|
||||||
sharedSecret,
|
|
||||||
inputBoxRef,
|
|
||||||
} = useChat();
|
|
||||||
|
|
||||||
const scrollRef = React.useRef<HTMLDivElement | null>(null);
|
const scrollRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const isMobile = useIsMobile();
|
const topSentinelRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const stickyBottomThreshold = isMobile ? 220 : 140;
|
const didInitialScrollRef = React.useRef(false);
|
||||||
|
const userScrolledUpRef = React.useRef(false);
|
||||||
const [hasScrolledToBottomInitially, setHasScrolledToBottomInitially] =
|
|
||||||
React.useState(false);
|
|
||||||
const [lastLiveMessageCount, setLastLiveMessageCount] = React.useState(0);
|
|
||||||
const [prependAnchor, setPrependAnchor] = React.useState<{
|
|
||||||
totalSize: number;
|
|
||||||
scrollTop: number;
|
|
||||||
} | null>(null);
|
|
||||||
const [scrollWidth, setScrollWidth] = React.useState(0);
|
|
||||||
const [scrollHeight, setScrollHeight] = React.useState(0);
|
|
||||||
const [inputHeight, setInputHeight] = React.useState(0);
|
|
||||||
const [measuredHeights, setMeasuredHeights] = React.useState<
|
|
||||||
Record<number, number>
|
|
||||||
>({});
|
|
||||||
const measurementRefs = React.useRef(new Map<number, HTMLDivElement>());
|
|
||||||
const shouldRestoreBottomOnResizeRef = React.useRef(false);
|
|
||||||
const isAtBottomRef = React.useRef(true);
|
const isAtBottomRef = React.useRef(true);
|
||||||
const shouldRestoreBottomOnFocusResizeRef = React.useRef(false);
|
|
||||||
|
const [messageUsers, setMessageUsers] = React.useState<
|
||||||
|
Record<string, User | undefined>
|
||||||
|
>({});
|
||||||
|
const [lastLiveMessageCount, setLastLiveMessageCount] = React.useState(0);
|
||||||
|
const [didInitialScroll, setDidInitialScroll] = React.useState(false);
|
||||||
|
const [viewportHeight, setViewportHeight] = React.useState(0);
|
||||||
|
const [value, setValue] = React.useState("");
|
||||||
|
|
||||||
const hasValidChatUser = Number.isSafeInteger(userId) && userId > 0;
|
const hasValidChatUser = Number.isSafeInteger(userId) && userId > 0;
|
||||||
const hasSharedSecret = sharedSecret.length > 0;
|
const hasSharedSecret = sharedSecret.length > 0;
|
||||||
|
|
@ -69,18 +81,55 @@ export default function Screen() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
clearLiveMessages();
|
||||||
|
didInitialScrollRef.current = false;
|
||||||
|
userScrolledUpRef.current = false;
|
||||||
|
isAtBottomRef.current = true;
|
||||||
|
setDidInitialScroll(false);
|
||||||
|
setLastLiveMessageCount(0);
|
||||||
|
}, [clearLiveMessages, userId]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!hasValidChatUser) {
|
||||||
|
setMessageUsers({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let active = true;
|
||||||
|
|
||||||
|
const loadMessageUser = async (
|
||||||
|
key: string,
|
||||||
|
resolveUserId: () => Promise<number>,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const resolvedUserId = await resolveUserId();
|
||||||
|
const value = await getUser(resolvedUserId);
|
||||||
|
|
||||||
|
if (active) {
|
||||||
|
setMessageUsers((prev) => ({ ...prev, [key]: value }));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (active) {
|
||||||
|
setMessageUsers((prev) => ({ ...prev, [key]: failedUser }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setMessageUsers({});
|
||||||
|
void loadMessageUser("peer", async () => userId);
|
||||||
|
void loadMessageUser("own", async () => Number(await load("user_id")));
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [getUser, hasValidChatUser, load, userId]);
|
||||||
|
|
||||||
const historicalMessages = React.useMemo(() => {
|
const historicalMessages = React.useMemo(() => {
|
||||||
const pages = messagesQuery.data?.pages ?? [];
|
const pages = messagesQuery.data?.pages ?? [];
|
||||||
return [...pages].reverse().flat();
|
return [...pages].reverse().flat();
|
||||||
}, [messagesQuery.data]);
|
}, [messagesQuery.data]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
clearLiveMessages();
|
|
||||||
setHasScrolledToBottomInitially(false);
|
|
||||||
setLastLiveMessageCount(0);
|
|
||||||
setPrependAnchor(null);
|
|
||||||
}, [userId, clearLiveMessages]);
|
|
||||||
|
|
||||||
const liveMessagesSnapshot = liveMessages();
|
const liveMessagesSnapshot = liveMessages();
|
||||||
|
|
||||||
const messages = React.useMemo(() => {
|
const messages = React.useMemo(() => {
|
||||||
|
|
@ -97,51 +146,67 @@ export default function Screen() {
|
||||||
|
|
||||||
return [...historicalMessages, ...liveWithoutDuplicates];
|
return [...historicalMessages, ...liveWithoutDuplicates];
|
||||||
}, [historicalMessages, liveMessagesSnapshot]);
|
}, [historicalMessages, liveMessagesSnapshot]);
|
||||||
|
|
||||||
|
const messageChunks = React.useMemo(() => {
|
||||||
|
const chunks: Array<{
|
||||||
|
key: number;
|
||||||
|
messages: typeof messages;
|
||||||
|
startIndex: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (let end = messages.length; end > 0; end -= MESSAGES_PER_VIRTUAL_ROW) {
|
||||||
|
const start = Math.max(0, end - MESSAGES_PER_VIRTUAL_ROW);
|
||||||
|
const chunkMessages = messages.slice(start, end);
|
||||||
|
const firstMessage = chunkMessages[0];
|
||||||
|
|
||||||
|
if (firstMessage) {
|
||||||
|
chunks.push({
|
||||||
|
key: firstMessage.send_time,
|
||||||
|
messages: chunkMessages,
|
||||||
|
startIndex: start,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks;
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
const shouldShowConversationStart =
|
const shouldShowConversationStart =
|
||||||
!!messagesQuery.data && !messagesQuery.hasNextPage;
|
!!messagesQuery.data && !messagesQuery.hasNextPage;
|
||||||
const virtualRowCount =
|
const virtualRowCount =
|
||||||
messages.length + (shouldShowConversationStart ? 1 : 0);
|
messageChunks.length + (shouldShowConversationStart ? 1 : 0);
|
||||||
|
|
||||||
const getItemKey = React.useCallback(
|
const getItemKey = React.useCallback(
|
||||||
(index: number) => {
|
(index: number) => {
|
||||||
if (shouldShowConversationStart && index === 0) {
|
if (shouldShowConversationStart && index === messageChunks.length) {
|
||||||
return "conversation-start";
|
return "conversation-start";
|
||||||
}
|
}
|
||||||
|
|
||||||
const messageIndex = shouldShowConversationStart ? index - 1 : index;
|
return messageChunks[index]?.key ?? index;
|
||||||
return messages[messageIndex]?.send_time ?? index;
|
|
||||||
},
|
},
|
||||||
[shouldShowConversationStart, messages],
|
[messageChunks, shouldShowConversationStart],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setMeasurementRef = React.useCallback(
|
const estimateSize = React.useCallback(
|
||||||
(sendTime: number, element: HTMLDivElement | null) => {
|
|
||||||
if (!element) {
|
|
||||||
measurementRefs.current.delete(sendTime);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
measurementRefs.current.set(sendTime, element);
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const estimateMessageSize = React.useCallback(
|
|
||||||
(index: number) => {
|
(index: number) => {
|
||||||
if (shouldShowConversationStart && index === 0) {
|
const isConversationStart =
|
||||||
|
shouldShowConversationStart && index === messageChunks.length;
|
||||||
|
if (isConversationStart) {
|
||||||
return FALLBACK_MESSAGE_HEIGHT;
|
return FALLBACK_MESSAGE_HEIGHT;
|
||||||
}
|
}
|
||||||
|
|
||||||
const messageIndex = shouldShowConversationStart ? index - 1 : index;
|
const chunk = messageChunks[index];
|
||||||
const sendTime = messages[messageIndex]?.send_time;
|
|
||||||
|
|
||||||
if (sendTime === undefined) {
|
if (!chunk) {
|
||||||
return FALLBACK_MESSAGE_HEIGHT;
|
return FALLBACK_MESSAGE_HEIGHT;
|
||||||
}
|
}
|
||||||
|
|
||||||
return measuredHeights[sendTime] ?? FALLBACK_MESSAGE_HEIGHT;
|
return chunk.messages.reduce(
|
||||||
|
(total, message) => total + getEstimatedMessageHeight(message),
|
||||||
|
0,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[measuredHeights, messages, shouldShowConversationStart],
|
[messageChunks, shouldShowConversationStart],
|
||||||
);
|
);
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/incompatible-library
|
// eslint-disable-next-line react-hooks/incompatible-library
|
||||||
|
|
@ -149,9 +214,12 @@ export default function Screen() {
|
||||||
count: virtualRowCount,
|
count: virtualRowCount,
|
||||||
getScrollElement: () => scrollRef.current,
|
getScrollElement: () => scrollRef.current,
|
||||||
getItemKey,
|
getItemKey,
|
||||||
estimateSize: estimateMessageSize,
|
estimateSize,
|
||||||
overscan: isMobile ? 10 : 6,
|
overscan: 2,
|
||||||
});
|
});
|
||||||
|
const totalSize = virtualizer.getTotalSize();
|
||||||
|
const contentHeight = Math.max(totalSize, viewportHeight);
|
||||||
|
const verticalOffset = Math.max(0, viewportHeight - totalSize);
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
const element = scrollRef.current;
|
const element = scrollRef.current;
|
||||||
|
|
@ -159,14 +227,13 @@ export default function Screen() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateDimensions = () => {
|
const updateViewportHeight = () => {
|
||||||
setScrollWidth(element.clientWidth);
|
setViewportHeight(element.clientHeight);
|
||||||
setScrollHeight(element.clientHeight);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
updateDimensions();
|
updateViewportHeight();
|
||||||
|
|
||||||
const observer = new ResizeObserver(updateDimensions);
|
const observer = new ResizeObserver(updateViewportHeight);
|
||||||
observer.observe(element);
|
observer.observe(element);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|
@ -175,277 +242,134 @@ export default function Screen() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
const frame = requestAnimationFrame(() => {
|
if (didInitialScrollRef.current || virtualRowCount === 0) {
|
||||||
virtualizer.measure();
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelAnimationFrame(frame);
|
|
||||||
};
|
|
||||||
}, [inputHeight, measuredHeights, scrollWidth, virtualizer]);
|
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
|
||||||
if (typeof ResizeObserver === "undefined") {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const measureHeights = () => {
|
|
||||||
setMeasuredHeights((prev) => {
|
|
||||||
let changed = false;
|
|
||||||
const next: Record<number, number> = {};
|
|
||||||
|
|
||||||
for (const message of messages) {
|
|
||||||
const element = measurementRefs.current.get(message.send_time);
|
|
||||||
const measuredHeight = element?.getBoundingClientRect().height;
|
|
||||||
const nextHeight =
|
|
||||||
typeof measuredHeight === "number" &&
|
|
||||||
Number.isFinite(measuredHeight)
|
|
||||||
? Math.ceil(measuredHeight)
|
|
||||||
: (prev[message.send_time] ?? FALLBACK_MESSAGE_HEIGHT);
|
|
||||||
|
|
||||||
next[message.send_time] = nextHeight;
|
|
||||||
|
|
||||||
if (prev[message.send_time] !== nextHeight) {
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!changed && Object.keys(prev).length === messages.length) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
measureHeights();
|
|
||||||
|
|
||||||
const observer = new ResizeObserver(() => {
|
|
||||||
measureHeights();
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const message of messages) {
|
|
||||||
const element = measurementRefs.current.get(message.send_time);
|
|
||||||
if (element) {
|
|
||||||
observer.observe(element);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
observer.disconnect();
|
|
||||||
};
|
|
||||||
}, [messages, scrollWidth]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (typeof window === "undefined") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const restoreBottomAfterResize = () => {
|
|
||||||
shouldRestoreBottomOnResizeRef.current =
|
|
||||||
isAtBottomRef.current || shouldRestoreBottomOnFocusResizeRef.current;
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
virtualizer.measure();
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = 0;
|
||||||
|
}
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (!scrollRef.current || !shouldRestoreBottomOnResizeRef.current) {
|
didInitialScrollRef.current = true;
|
||||||
return;
|
setDidInitialScroll(true);
|
||||||
}
|
|
||||||
|
|
||||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
||||||
isAtBottomRef.current = true;
|
|
||||||
shouldRestoreBottomOnFocusResizeRef.current = false;
|
|
||||||
shouldRestoreBottomOnResizeRef.current = false;
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
}, [virtualizer, virtualRowCount]);
|
||||||
|
|
||||||
window.addEventListener("resize", restoreBottomAfterResize);
|
|
||||||
window.visualViewport?.addEventListener("resize", restoreBottomAfterResize);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("resize", restoreBottomAfterResize);
|
|
||||||
window.visualViewport?.removeEventListener(
|
|
||||||
"resize",
|
|
||||||
restoreBottomAfterResize,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}, [virtualizer]);
|
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
|
||||||
const element = inputBoxRef.current;
|
|
||||||
if (!element || typeof ResizeObserver === "undefined") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateHeight = () => {
|
|
||||||
setInputHeight(element.getBoundingClientRect().height);
|
|
||||||
};
|
|
||||||
|
|
||||||
updateHeight();
|
|
||||||
|
|
||||||
const observer = new ResizeObserver(updateHeight);
|
|
||||||
observer.observe(element);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
observer.disconnect();
|
|
||||||
};
|
|
||||||
}, [inputBoxRef]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const element = inputBoxRef.current;
|
|
||||||
if (!element) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFocusIn = () => {
|
|
||||||
if (!scrollRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
shouldRestoreBottomOnFocusResizeRef.current =
|
|
||||||
getDistanceFromBottom(scrollRef.current) <= stickyBottomThreshold;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFocusOut = () => {
|
|
||||||
shouldRestoreBottomOnFocusResizeRef.current = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
element.addEventListener("focusin", handleFocusIn);
|
|
||||||
element.addEventListener("focusout", handleFocusOut);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
element.removeEventListener("focusin", handleFocusIn);
|
|
||||||
element.removeEventListener("focusout", handleFocusOut);
|
|
||||||
};
|
|
||||||
}, [inputBoxRef, stickyBottomThreshold]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads the next page when the scroll container reaches the top.
|
|
||||||
* @returns Promise that resolves once pagination handling completes.
|
|
||||||
*/
|
|
||||||
const onScroll = React.useCallback(async () => {
|
|
||||||
if (!scrollRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (scrollRef.current.scrollTop > 96) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (messagesQuery.isFetchingNextPage || !messagesQuery.hasNextPage) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setPrependAnchor({
|
|
||||||
totalSize: virtualizer.getTotalSize(),
|
|
||||||
scrollTop: scrollRef.current.scrollTop,
|
|
||||||
});
|
|
||||||
|
|
||||||
await messagesQuery.fetchNextPage();
|
|
||||||
}, [messagesQuery, virtualizer]);
|
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
|
||||||
if (
|
|
||||||
!scrollRef.current ||
|
|
||||||
hasScrolledToBottomInitially ||
|
|
||||||
virtualRowCount === 0
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let rafId: number;
|
|
||||||
let stableFrames = 0;
|
|
||||||
let lastScrollHeight = 0;
|
|
||||||
|
|
||||||
const settleToBottom = () => {
|
|
||||||
if (!scrollRef.current || hasScrolledToBottomInitially) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
||||||
const currentScrollHeight = scrollRef.current.scrollHeight;
|
|
||||||
|
|
||||||
if (currentScrollHeight === lastScrollHeight) {
|
|
||||||
stableFrames++;
|
|
||||||
} else {
|
|
||||||
stableFrames = 0;
|
|
||||||
lastScrollHeight = currentScrollHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stableFrames >= 2) {
|
|
||||||
isAtBottomRef.current = true;
|
|
||||||
setHasScrolledToBottomInitially(true);
|
|
||||||
} else {
|
|
||||||
rafId = requestAnimationFrame(settleToBottom);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
settleToBottom();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelAnimationFrame(rafId);
|
|
||||||
};
|
|
||||||
}, [hasScrolledToBottomInitially, virtualRowCount]);
|
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
const count = liveMessagesSnapshot.length;
|
const count = liveMessagesSnapshot.length;
|
||||||
|
|
||||||
if (!scrollRef.current) {
|
if (count > lastLiveMessageCount && isAtBottomRef.current) {
|
||||||
return;
|
requestAnimationFrame(() => {
|
||||||
}
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = 0;
|
||||||
if (count > lastLiveMessageCount) {
|
|
||||||
const shouldStickToBottom =
|
|
||||||
getDistanceFromBottom(scrollRef.current) <= stickyBottomThreshold;
|
|
||||||
|
|
||||||
if (shouldStickToBottom) {
|
|
||||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
||||||
isAtBottomRef.current = true;
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setLastLiveMessageCount(count);
|
setLastLiveMessageCount(count);
|
||||||
}, [
|
}, [
|
||||||
lastLiveMessageCount,
|
lastLiveMessageCount,
|
||||||
liveMessagesSnapshot.length,
|
liveMessagesSnapshot.length,
|
||||||
stickyBottomThreshold,
|
virtualRowCount,
|
||||||
|
virtualizer,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
React.useEffect(() => {
|
||||||
if (
|
if (
|
||||||
!scrollRef.current ||
|
viewportHeight === 0 ||
|
||||||
!prependAnchor ||
|
totalSize > viewportHeight ||
|
||||||
messagesQuery.isFetchingNextPage
|
messagesQuery.isFetchingNextPage ||
|
||||||
|
!messagesQuery.hasNextPage
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const delta = virtualizer.getTotalSize() - prependAnchor.totalSize;
|
void messagesQuery.fetchNextPage();
|
||||||
scrollRef.current.scrollTop = prependAnchor.scrollTop + delta;
|
}, [didInitialScroll, messagesQuery, totalSize, viewportHeight]);
|
||||||
setPrependAnchor(null);
|
|
||||||
}, [messagesQuery.isFetchingNextPage, prependAnchor, virtualizer]);
|
|
||||||
|
|
||||||
/**
|
React.useLayoutEffect(() => {
|
||||||
* Triggers asynchronous scroll pagination without returning a promise to JSX.
|
if (
|
||||||
* @returns Void.
|
!didInitialScroll ||
|
||||||
*/
|
userScrolledUpRef.current ||
|
||||||
const handleContainerScroll = React.useCallback((): void => {
|
virtualRowCount === 0
|
||||||
if (scrollRef.current) {
|
) {
|
||||||
isAtBottomRef.current =
|
return;
|
||||||
getDistanceFromBottom(scrollRef.current) <= stickyBottomThreshold;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void onScroll();
|
requestAnimationFrame(() => {
|
||||||
}, [onScroll, stickyBottomThreshold]);
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [didInitialScroll, virtualRowCount]);
|
||||||
|
|
||||||
const [value, setValue] = React.useState("");
|
React.useEffect(() => {
|
||||||
const totalSize = virtualizer.getTotalSize();
|
const root = scrollRef.current;
|
||||||
const verticalOffset = Math.max(0, scrollHeight - totalSize);
|
const sentinel = topSentinelRef.current;
|
||||||
const contentHeight = Math.max(totalSize, scrollHeight);
|
|
||||||
|
if (!root || !sentinel || !didInitialScroll) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const entry = entries[0];
|
||||||
|
if (
|
||||||
|
shouldFetchPreviousPage({
|
||||||
|
entry,
|
||||||
|
hasNextPage: messagesQuery.hasNextPage,
|
||||||
|
isFetchingNextPage: messagesQuery.isFetchingNextPage,
|
||||||
|
userScrolledUp: userScrolledUpRef.current,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
void messagesQuery.fetchNextPage();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ root, rootMargin: "240px 0px 0px 0px" },
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(sentinel);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, [didInitialScroll, messagesQuery, virtualRowCount]);
|
||||||
|
|
||||||
|
const handleContainerScroll = React.useCallback(() => {
|
||||||
|
if (!scrollRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isAtBottomRef.current = scrollRef.current.scrollTop <= 140;
|
||||||
|
|
||||||
|
if (didInitialScrollRef.current && scrollRef.current.scrollTop > 140) {
|
||||||
|
userScrolledUpRef.current = true;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const element = scrollRef.current;
|
||||||
|
if (!element) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleWheel = (event: WheelEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
element.scrollTop -= event.deltaY;
|
||||||
|
handleContainerScroll();
|
||||||
|
};
|
||||||
|
|
||||||
|
element.addEventListener("wheel", handleWheel, { passive: false });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
element.removeEventListener("wheel", handleWheel);
|
||||||
|
};
|
||||||
|
}, [handleContainerScroll]);
|
||||||
|
|
||||||
// Render
|
|
||||||
if (!hasValidChatUser) {
|
if (!hasValidChatUser) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full flex items-center justify-center text-xl text-foreground/80">
|
<div className="w-full h-full flex items-center justify-center text-xl text-foreground/80">
|
||||||
|
|
@ -462,12 +386,13 @@ export default function Screen() {
|
||||||
className="min-h-0 flex-1 overflow-y-auto"
|
className="min-h-0 flex-1 overflow-y-auto"
|
||||||
style={{
|
style={{
|
||||||
overflowAnchor: "none",
|
overflowAnchor: "none",
|
||||||
paddingBottom: "22px",
|
paddingTop: "22px",
|
||||||
|
transform: "scaleY(-1)",
|
||||||
}}
|
}}
|
||||||
onScroll={handleContainerScroll}
|
onScroll={handleContainerScroll}
|
||||||
>
|
>
|
||||||
{messagesQuery.isPending && (
|
{messagesQuery.isPending && (
|
||||||
<div className="flex items-center justify-center pt-10">
|
<div className="flex items-center justify-center pt-10 scale-y-[-1]">
|
||||||
<p className="text-foreground/45 flex gap-1 items-center justify-center">
|
<p className="text-foreground/45 flex gap-1 items-center justify-center">
|
||||||
<Loader2 size={18} className="animate-spin" />
|
<Loader2 size={18} className="animate-spin" />
|
||||||
Loading...
|
Loading...
|
||||||
|
|
@ -475,18 +400,23 @@ export default function Screen() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{messagesQuery.isFetchingNextPage && (
|
{messagesQuery.isFetchingNextPage && (
|
||||||
<div className="flex items-center justify-center pt-3 pb-1">
|
<div className="flex items-center justify-center pt-3 pb-1 scale-y-[-1]">
|
||||||
<Loader2 size={16} className="animate-spin text-foreground/45" />
|
<Loader2 size={16} className="animate-spin text-foreground/45" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className="relative w-full"
|
className="relative w-full"
|
||||||
style={{
|
style={{ height: `${contentHeight}px` }}
|
||||||
height: `${contentHeight}px`,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
|
<div
|
||||||
|
ref={topSentinelRef}
|
||||||
|
className="absolute bottom-0 left-0 h-px w-full"
|
||||||
|
/>
|
||||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||||
if (shouldShowConversationStart && virtualRow.index === 0) {
|
if (
|
||||||
|
shouldShowConversationStart &&
|
||||||
|
virtualRow.index === messageChunks.length
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key="conversation-start"
|
key="conversation-start"
|
||||||
|
|
@ -497,10 +427,10 @@ export default function Screen() {
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
width: "100%",
|
width: "100%",
|
||||||
transform: `translateY(${virtualRow.start + verticalOffset}px)`,
|
transform: `translateY(${verticalOffset + virtualRow.start}px)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="w-full flex justify-start">
|
<div className="w-full flex justify-start scale-y-[-1]">
|
||||||
<div className="text-sm text-foreground/55 px-2.5">
|
<div className="text-sm text-foreground/55 px-2.5">
|
||||||
Conversation start
|
Conversation start
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -509,14 +439,29 @@ export default function Screen() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const messageIndex = shouldShowConversationStart
|
const chunkIndex = virtualRow.index;
|
||||||
? virtualRow.index - 1
|
const chunk = messageChunks[chunkIndex];
|
||||||
: virtualRow.index;
|
if (!chunk) {
|
||||||
const message = messages[messageIndex];
|
|
||||||
if (!message) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={chunk.key}
|
||||||
|
data-index={virtualRow.index}
|
||||||
|
className="flex flex-col"
|
||||||
|
ref={virtualizer.measureElement}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: "100%",
|
||||||
|
transform: `translateY(${verticalOffset + virtualRow.start}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col scale-y-[-1]">
|
||||||
|
{chunk.messages.map((message, chunkMessageIndex) => {
|
||||||
|
const messageIndex = chunk.startIndex + chunkMessageIndex;
|
||||||
const lastMessage = messages[messageIndex - 1];
|
const lastMessage = messages[messageIndex - 1];
|
||||||
const isGrouped =
|
const isGrouped =
|
||||||
lastMessage &&
|
lastMessage &&
|
||||||
|
|
@ -525,19 +470,19 @@ export default function Screen() {
|
||||||
Math.round(message.send_time / 10000);
|
Math.round(message.send_time / 10000);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Message
|
||||||
key={message.send_time}
|
key={message.send_time}
|
||||||
data-index={virtualRow.index}
|
grouped={isGrouped}
|
||||||
ref={virtualizer.measureElement}
|
message={message}
|
||||||
style={{
|
user={
|
||||||
position: "absolute",
|
message.sent_by_self
|
||||||
top: 0,
|
? (messageUsers.own ?? null)
|
||||||
left: 0,
|
: (messageUsers.peer ?? null)
|
||||||
width: "100%",
|
}
|
||||||
transform: `translateY(${virtualRow.start + verticalOffset}px)`,
|
/>
|
||||||
}}
|
);
|
||||||
>
|
})}
|
||||||
<Message grouped={isGrouped} message={message} />
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -546,31 +491,6 @@ export default function Screen() {
|
||||||
<div className="z-10 shrink-0">
|
<div className="z-10 shrink-0">
|
||||||
<InputComponent setValue={setValue} value={value} />
|
<InputComponent setValue={setValue} value={value} />
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
className="pointer-events-none absolute left-0 top-0 -z-10 overflow-hidden opacity-0"
|
|
||||||
style={{ width: scrollWidth > 0 ? `${scrollWidth}px` : "100%" }}
|
|
||||||
>
|
|
||||||
{messages.map((message, messageIndex) => {
|
|
||||||
const lastMessage = messages[messageIndex - 1];
|
|
||||||
const isGrouped =
|
|
||||||
lastMessage &&
|
|
||||||
lastMessage.sent_by_self === message.sent_by_self &&
|
|
||||||
Math.round(lastMessage.send_time / 10000) ===
|
|
||||||
Math.round(message.send_time / 10000);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`measure-${message.send_time}`}
|
|
||||||
ref={(element) => {
|
|
||||||
setMeasurementRef(message.send_time, element);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Message grouped={isGrouped} message={message} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,4 +10,6 @@ export type LiveMessage = RawMessage & {
|
||||||
localId: string;
|
localId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PAGE_SIZE = 50;
|
export const PAGE_SIZE = 30;
|
||||||
|
export const FALLBACK_MESSAGE_HEIGHT = 28;
|
||||||
|
export const MESSAGES_PER_VIRTUAL_ROW = 30;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue