Files
desktop/app/components/Messages/Messages.tsx
rosetta 83f38dc63f 'init'
2026-01-30 05:01:05 +02:00

286 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}