286 lines
13 KiB
TypeScript
286 lines
13 KiB
TypeScript
import { useDialog } from "@/app/providers/DialogProvider/useDialog";
|
||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||
import { Message, MessageSystem } from "./Message";
|
||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||
import { usePublicKey } from "@/app/providers/AccountProvider/usePublicKey";
|
||
import { MessageSkeleton } from "../MessageSkeleton/MessageSkeleton";
|
||
import { ScrollArea } from "@mantine/core";
|
||
import { MESSAGE_AVATAR_NO_RENDER_TIME_DIFF_S, SCROLL_TOP_IN_MESSAGES_TO_VIEW_AFFIX } from "@/app/constants";
|
||
import { DialogAffix } from "../DialogAffix/DialogAffix";
|
||
import { useReplyMessages } from "@/app/providers/DialogProvider/useReplyMessages";
|
||
import { useSetting } from "@/app/providers/SettingsProvider/useSetting";
|
||
|
||
export function Messages() {
|
||
const colors = useRosettaColors();
|
||
const publicKey = usePublicKey();
|
||
const { messages, dialog, loadMessagesToTop, loading } = useDialog();
|
||
const { replyMessages, isSelectionStarted } = useReplyMessages();
|
||
|
||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||
const lastMessageRef = useRef<HTMLDivElement | null>(null);
|
||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||
const shouldAutoScrollRef = useRef(true);
|
||
const isFirstRenderRef = useRef(true);
|
||
const previousScrollHeightRef = useRef(0);
|
||
|
||
const [affix, setAffix] = useState(false);
|
||
const [wallpaper] = useSetting<string>
|
||
('wallpaper', '');
|
||
|
||
|
||
const scrollToBottom = useCallback((smooth: boolean = false) => {
|
||
if (!viewportRef.current) return;
|
||
|
||
requestAnimationFrame(() => {
|
||
if (!viewportRef.current) return;
|
||
|
||
viewportRef.current.scrollTo({
|
||
top: viewportRef.current.scrollHeight,
|
||
behavior: smooth ? 'smooth' : 'auto'
|
||
});
|
||
});
|
||
}, []);
|
||
|
||
// Сброс состояния при смене диалога
|
||
useEffect(() => {
|
||
isFirstRenderRef.current = true;
|
||
shouldAutoScrollRef.current = true;
|
||
previousScrollHeightRef.current = 0;
|
||
setAffix(false);
|
||
}, [dialog]);
|
||
|
||
// IntersectionObserver - отслеживаем видимость последнего сообщения
|
||
useEffect(() => {
|
||
if (!lastMessageRef.current || !viewportRef.current || loading) return;
|
||
|
||
const observer = new IntersectionObserver(
|
||
(entries) => {
|
||
const entry = entries[0];
|
||
//console.info("IntersectionObserver triggered ", entry.isIntersecting);
|
||
//shouldAutoScrollRef.current = entry.isIntersecting;
|
||
|
||
// Если последнее сообщение видно, скрываем кнопку "вниз"
|
||
if (entry.isIntersecting) {
|
||
setAffix(false);
|
||
}
|
||
},
|
||
{
|
||
root: viewportRef.current,
|
||
threshold: 0.1
|
||
}
|
||
);
|
||
|
||
observer.observe(lastMessageRef.current);
|
||
|
||
return () => observer.disconnect();
|
||
}, [messages.length, loading]);
|
||
|
||
// MutationObserver - отслеживаем изменения контента (загрузка картинок, видео)
|
||
useEffect(() => {
|
||
if (!contentRef.current) return;
|
||
|
||
const observer = new MutationObserver(() => {
|
||
// Скроллим только если нужен авто-скролл
|
||
if (shouldAutoScrollRef.current) {
|
||
scrollToBottom(true);
|
||
}
|
||
});
|
||
|
||
observer.observe(contentRef.current, {
|
||
childList: true,
|
||
subtree: true,
|
||
attributes: true,
|
||
attributeFilter: ['src', 'style', 'class']
|
||
});
|
||
|
||
return () => observer.disconnect();
|
||
}, [scrollToBottom]);
|
||
|
||
// Первый рендер - скроллим вниз моментально
|
||
useEffect(() => {
|
||
if (loading || messages.length === 0) return;
|
||
|
||
if (isFirstRenderRef.current) {
|
||
scrollToBottom(false);
|
||
isFirstRenderRef.current = false;
|
||
}
|
||
}, [loading, messages.length, scrollToBottom]);
|
||
|
||
useEffect(() => {
|
||
if(affix){
|
||
shouldAutoScrollRef.current = false;
|
||
} else {
|
||
shouldAutoScrollRef.current = true;
|
||
}
|
||
}, [affix]);
|
||
|
||
// Новое сообщение - скроллим если пользователь внизу или это его сообщение
|
||
useEffect(() => {
|
||
if (loading || messages.length === 0 || isFirstRenderRef.current) return;
|
||
|
||
const lastMessage = messages[messages.length - 1];
|
||
|
||
// Скроллим если пользователь внизу или это его собственное сообщение
|
||
if ((shouldAutoScrollRef.current || lastMessage.from_me) && !affix) {
|
||
/**
|
||
* Скролл только если пользователь не читает сейчас старую переписку
|
||
* (!affix))
|
||
*/
|
||
//console.info("Scroll because", shouldAutoScrollRef.current);
|
||
scrollToBottom(true);
|
||
}
|
||
}, [messages.length, loading, affix, scrollToBottom]);
|
||
|
||
// Восстановление позиции после загрузки старых сообщений
|
||
useEffect(() => {
|
||
if (!viewportRef.current || previousScrollHeightRef.current === 0) return;
|
||
|
||
const scrollDiff = viewportRef.current.scrollHeight - previousScrollHeightRef.current;
|
||
if (scrollDiff > 0) {
|
||
viewportRef.current.scrollTop = scrollDiff;
|
||
previousScrollHeightRef.current = 0;
|
||
}
|
||
}, [messages.length]);
|
||
|
||
// Скролл при отправке reply сообщения
|
||
useEffect(() => {
|
||
if (replyMessages.messages.length === 0 || isSelectionStarted()) return;
|
||
scrollToBottom(true);
|
||
}, [replyMessages.messages.length]);
|
||
|
||
const loadMessagesToScrollAreaTop = async () => {
|
||
if (!viewportRef.current) return;
|
||
|
||
previousScrollHeightRef.current = viewportRef.current.scrollHeight;
|
||
await loadMessagesToTop();
|
||
};
|
||
|
||
const onAffixClick = () => {
|
||
shouldAutoScrollRef.current = true;
|
||
scrollToBottom(true);
|
||
};
|
||
|
||
return (
|
||
<ScrollArea.Autosize
|
||
type="hover"
|
||
offsetScrollbars="y"
|
||
scrollHideDelay={1500}
|
||
scrollbarSize={7}
|
||
scrollbars="y"
|
||
h={100}
|
||
style={{ flexGrow: 1 }}
|
||
viewportRef={viewportRef}
|
||
bg={colors.boxColor}
|
||
styles={{
|
||
viewport: {
|
||
backgroundImage: `url(${wallpaper})`,
|
||
backgroundSize: 'cover',
|
||
backgroundPosition: 'center',
|
||
backgroundRepeat: 'no-repeat',
|
||
padding: '10px',
|
||
height: '100%'
|
||
},
|
||
root: {
|
||
height: '100%'
|
||
}
|
||
}}
|
||
onScrollPositionChange={(scroll) => {
|
||
if (!viewportRef.current) return;
|
||
|
||
// Загружаем старые сообщения при достижении верха
|
||
if (scroll.y === 0 && !loading && messages.length >= 20) {
|
||
loadMessagesToScrollAreaTop();
|
||
}
|
||
|
||
// Показываем/скрываем кнопку "вниз"
|
||
const distanceFromBottom =
|
||
(viewportRef.current.scrollHeight - viewportRef.current.clientHeight) - scroll.y;
|
||
|
||
setAffix(distanceFromBottom > SCROLL_TOP_IN_MESSAGES_TO_VIEW_AFFIX);
|
||
}}
|
||
>
|
||
<div ref={contentRef}>
|
||
{loading &&
|
||
<>
|
||
<MessageSkeleton messageHeight={20}></MessageSkeleton>
|
||
<MessageSkeleton messageHeight={40}></MessageSkeleton>
|
||
<MessageSkeleton messageHeight={70}></MessageSkeleton>
|
||
<MessageSkeleton messageHeight={20}></MessageSkeleton>
|
||
<MessageSkeleton messageHeight={38}></MessageSkeleton>
|
||
</>
|
||
}
|
||
{!loading && messages.map((message, index) => {
|
||
const prevMessage = messages[index - 1];
|
||
const currentDate = new Date(message.timestamp).toDateString();
|
||
const prevDate = prevMessage ? new Date(prevMessage.timestamp).toDateString() : null;
|
||
const showSystem = prevDate !== currentDate;
|
||
const isLastMessage = index === messages.length - 1;
|
||
const isLastMessageInStack = isLastMessage || messages[index + 1].from_public_key !== message.from_public_key || (new Date(messages[index + 1].timestamp).toDateString() !== new Date(message.timestamp).toDateString()) || (messages[index + 1].timestamp - message.timestamp) >= (MESSAGE_AVATAR_NO_RENDER_TIME_DIFF_S * 1000) || (messages[index + 1].plain_message == "$a=Group created" || messages[index + 1].plain_message == "$a=Group joined");
|
||
|
||
return (
|
||
<React.Fragment key={message.message_id}>
|
||
{showSystem && (
|
||
<MessageSystem message={
|
||
(() => {
|
||
const messageDate = new Date(message.timestamp);
|
||
const today = new Date();
|
||
const yesterday = new Date();
|
||
yesterday.setDate(today.getDate() - 1);
|
||
|
||
const isToday = messageDate.toDateString() === today.toDateString();
|
||
const isYesterday = messageDate.toDateString() === yesterday.toDateString();
|
||
|
||
if (isToday) return "today";
|
||
if (isYesterday) return "yesterday";
|
||
return messageDate.toLocaleDateString('en-EN', {
|
||
day: 'numeric',
|
||
month: 'long',
|
||
year: 'numeric'
|
||
});
|
||
})()
|
||
} />
|
||
)}
|
||
{index > 0 &&
|
||
messages[index - 1].readed == 1 &&
|
||
message.readed == 0 &&
|
||
publicKey != message.from_public_key && (
|
||
<MessageSystem c={colors.brandColor} message="New messages" />
|
||
)
|
||
}
|
||
<div ref={isLastMessage ? lastMessageRef : undefined}>
|
||
{message.plain_message != "$a=Group created" && message.plain_message != "$a=Group joined" && (
|
||
<Message
|
||
is_last_message_in_stack={isLastMessageInStack}
|
||
chacha_key_plain={message.chacha_key}
|
||
from={message.from_public_key}
|
||
message={message.plain_message}
|
||
delivered={message.delivered}
|
||
timestamp={message.timestamp}
|
||
message_id={message.message_id}
|
||
attachments={message.attachments}
|
||
from_me={message.from_public_key == publicKey}
|
||
readed={message.readed == 1 || message.from_public_key == dialog}
|
||
avatar_no_render={
|
||
index > 0
|
||
&& messages[index - 1].from_public_key == message.from_public_key
|
||
&& (new Date(messages[index - 1].timestamp).toDateString() == new Date(message.timestamp).toDateString())
|
||
&& (message.timestamp - messages[index - 1].timestamp) < (MESSAGE_AVATAR_NO_RENDER_TIME_DIFF_S * 1000)
|
||
&& (messages[index - 1].plain_message != "$a=Group created" && messages[index - 1].plain_message != "$a=Group joined")
|
||
}
|
||
/>
|
||
)}
|
||
{message.plain_message == "$a=Group created" && (
|
||
<MessageSystem c={colors.success} message="Group created" />
|
||
)}
|
||
{message.plain_message == "$a=Group joined" && (
|
||
<MessageSystem c={colors.success} message="You joined the group" />
|
||
)}
|
||
</div>
|
||
</React.Fragment>
|
||
);
|
||
})}
|
||
</div>
|
||
<DialogAffix mounted={affix} onClick={onAffixClick} />
|
||
</ScrollArea.Autosize>
|
||
)
|
||
} |