'init'
This commit is contained in:
BIN
app/components/Messages/.DS_Store
vendored
Normal file
BIN
app/components/Messages/.DS_Store
vendored
Normal file
Binary file not shown.
369
app/components/Messages/Message.tsx
Normal file
369
app/components/Messages/Message.tsx
Normal file
@@ -0,0 +1,369 @@
|
||||
import { usePublicKey } from "@/app/providers/AccountProvider/usePublicKey";
|
||||
import { useUserInformation } from "@/app/providers/InformationProvider/useUserInformation";
|
||||
import { getInitialsColor, isMessageDeliveredByTime } from "@/app/utils/utils";
|
||||
import { Avatar, Box, Flex, MantineColor, Text, useComputedColorScheme, useMantineTheme } from "@mantine/core";
|
||||
import { IconCheck, IconChecks, IconCircleCheck, IconCircleCheckFilled, IconCircleX, IconClock, IconTextCaption } from "@tabler/icons-react";
|
||||
import { MessageError } from "../MessageError/MessageError";
|
||||
import { DeliveredMessageState } from "@/app/providers/DialogProvider/DialogProvider";
|
||||
import { Attachment, AttachmentType } from "@/app/providers/ProtocolProvider/protocol/packets/packet.message";
|
||||
import { MessageAttachments } from "../MessageAttachments/MessageAttachments";
|
||||
import { useAvatars } from "@/app/providers/AvatarProvider/useAvatars";
|
||||
import { useContextMenu } from "@/app/providers/ContextMenuProvider/useContextMenu";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { MessageReply, useReplyMessages } from "@/app/providers/DialogProvider/useReplyMessages";
|
||||
import { useSetting } from "@/app/providers/SettingsProvider/useSetting";
|
||||
import { useRosettaBreakpoints } from "@/app/hooks/useRosettaBreakpoints";
|
||||
import { TextParser } from "../TextParser/TextParser";
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { ATTACHMENTS_NOT_ALLOWED_TO_REPLY, ENTITY_LIMITS_TO_PARSE_IN_MESSAGE } from "@/app/constants";
|
||||
import { useDialog } from "@/app/providers/DialogProvider/useDialog";
|
||||
import { VerifiedBadge } from "../VerifiedBadge/VerifiedBadge";
|
||||
import { useGroupMembers } from "@/app/providers/InformationProvider/useGroupMembers";
|
||||
|
||||
|
||||
export enum MessageStyle {
|
||||
BUBBLES = 'bubbles',
|
||||
ROWS = 'rows'
|
||||
}
|
||||
|
||||
export interface MessageProps {
|
||||
message: string;
|
||||
from_me?: boolean;
|
||||
readed?: boolean;
|
||||
avatar_no_render?: boolean;
|
||||
delivered: DeliveredMessageState;
|
||||
from: string;
|
||||
timestamp: number;
|
||||
message_id: string;
|
||||
attachments: Attachment[];
|
||||
replyed?: boolean;
|
||||
is_last_message_in_stack?: boolean;
|
||||
chacha_key_plain: string;
|
||||
parent?: MessageProps;
|
||||
}
|
||||
|
||||
interface MessageSystemProps {
|
||||
message: string;
|
||||
c?: MantineColor;
|
||||
}
|
||||
|
||||
export function MessageSystem(props: MessageSystemProps) {
|
||||
const [wallpaper] = useSetting<string>
|
||||
('wallpaper', '');
|
||||
return (<>
|
||||
<Flex w={'100%'} mt={'xs'} mb={'xs'} justify={'center'} align={'center'}>
|
||||
<Box p={'sm'} style={{
|
||||
cursor: 'pointer',
|
||||
backgroundColor: wallpaper != '' ? (useComputedColorScheme() == 'light' ? 'white' : '#2C2E33') : 'transparent',
|
||||
maxWidth: 200,
|
||||
borderRadius: 10,
|
||||
padding: 0
|
||||
}}>
|
||||
<Flex h={8} align={'center'} justify={'center'}>
|
||||
<Text fz={12} c={props.c || 'gray'} fw={600}>
|
||||
{props.message}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Flex>
|
||||
</>);
|
||||
}
|
||||
|
||||
export function Message(props: MessageProps) {
|
||||
const computedTheme = useComputedColorScheme();
|
||||
const theme = useMantineTheme();
|
||||
const publicKey = usePublicKey();
|
||||
const openContextMenu = useContextMenu();
|
||||
const colors = useRosettaColors();
|
||||
const navigate = useNavigate();
|
||||
const { isSelectionStarted,
|
||||
selectMessage,
|
||||
deselectMessage,
|
||||
isMessageSelected,
|
||||
translateMessagesToDialogInput
|
||||
} = useReplyMessages();
|
||||
const { dialog } = useDialog();
|
||||
const { md } = useRosettaBreakpoints();
|
||||
const { members } = useGroupMembers(dialog);
|
||||
|
||||
const [showTimeInReplyMessages] = useSetting<boolean>
|
||||
('showTimeInReplyMessages', false);
|
||||
const [wallpaper] = useSetting<string>
|
||||
('wallpaper', '');
|
||||
|
||||
const [userInfo] = useUserInformation(publicKey);
|
||||
const [opponent] = useUserInformation(props.from);
|
||||
const user = props.from_me ? userInfo : {
|
||||
...opponent,
|
||||
avatar: ""
|
||||
};
|
||||
|
||||
const messageReply: MessageReply = {
|
||||
timestamp: props.timestamp,
|
||||
publicKey: user.publicKey,
|
||||
message: props.message,
|
||||
attachments: props.attachments.filter(a => a.type != AttachmentType.MESSAGES),
|
||||
message_id: props.message_id
|
||||
};
|
||||
|
||||
const avatars = useAvatars(user.publicKey);
|
||||
|
||||
const [messageStyle] = useSetting<MessageStyle>
|
||||
('messageStyle', MessageStyle.ROWS);
|
||||
|
||||
const computedMessageStyle = props.replyed ? MessageStyle.ROWS : messageStyle;
|
||||
|
||||
|
||||
const navigateToUserProfile = () => {
|
||||
if (isSelectionStarted()) {
|
||||
return;
|
||||
}
|
||||
navigate(`/main/profile/${user.publicKey}`);
|
||||
}
|
||||
|
||||
const canReply = () => {
|
||||
if (props.replyed) {
|
||||
return false;
|
||||
}
|
||||
if (messageReply.attachments.find((v) => ATTACHMENTS_NOT_ALLOWED_TO_REPLY.includes(v.type))) {
|
||||
return false;
|
||||
}
|
||||
if (messageReply.message.trim().length == 0 && messageReply.attachments.length == 0) {
|
||||
return false;
|
||||
}
|
||||
if (props.delivered != DeliveredMessageState.DELIVERED) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const onMessageSelectClick = () => {
|
||||
if (props.replyed || !canReply()) {
|
||||
return;
|
||||
}
|
||||
if (isMessageSelected(messageReply)) {
|
||||
deselectMessage(messageReply);
|
||||
} else {
|
||||
selectMessage(messageReply);
|
||||
}
|
||||
}
|
||||
|
||||
const onDobuleClick = () => {
|
||||
if (!canReply()) {
|
||||
return;
|
||||
}
|
||||
if (isSelectionStarted()) {
|
||||
return;
|
||||
}
|
||||
selectMessage(messageReply);
|
||||
translateMessagesToDialogInput(dialog);
|
||||
}
|
||||
|
||||
return (<>
|
||||
<Box onDoubleClick={onDobuleClick} onClick={isSelectionStarted() ? onMessageSelectClick : undefined} onContextMenu={() => !props.replyed && openContextMenu([
|
||||
{
|
||||
label: 'Copy Message',
|
||||
action: () => {
|
||||
navigator.clipboard.writeText(props.message);
|
||||
},
|
||||
icon: <IconTextCaption size={14}></IconTextCaption>,
|
||||
cond: async () => {
|
||||
return props.message.trim().length > 0;
|
||||
}
|
||||
},
|
||||
{
|
||||
label: !isMessageSelected(messageReply) ? 'Select' : 'Deselect',
|
||||
action: onMessageSelectClick,
|
||||
icon: !isMessageSelected(messageReply) ? <IconCircleCheck size={14}></IconCircleCheck> : <IconCircleX color={theme.colors.red[5]} size={14}></IconCircleX>,
|
||||
cond: () => {
|
||||
return canReply();
|
||||
},
|
||||
}
|
||||
])} p={'sm'} pt={props.avatar_no_render ? 0 : 'sm'} style={{
|
||||
cursor: 'pointer',
|
||||
userSelect: 'auto'
|
||||
}}>
|
||||
{computedMessageStyle == MessageStyle.ROWS && (
|
||||
<Flex direction={'row'} justify={'space-between'} gap={'sm'}>
|
||||
<Flex direction={'row'} gap={'sm'}>
|
||||
{(!props.avatar_no_render && (md || !props.replyed)) && <Avatar onClick={navigateToUserProfile} src={avatars.length > 0 ? avatars[0].avatar : undefined} name={user.title} variant={props.parent ? 'filled' : 'light'} color="initials"></Avatar>}
|
||||
<Flex direction={'column'}>
|
||||
<Flex direction={'row'} gap={3} align={'center'}>
|
||||
{!props.avatar_no_render && (
|
||||
/** Только если не установлен флаг который
|
||||
* запрещает рендеринг аватарки и имени*/
|
||||
<>
|
||||
<Text style={{
|
||||
userSelect: 'none'
|
||||
}} size={'sm'} onClick={navigateToUserProfile} c={getInitialsColor(user.title)} fw={500}>{user.title}</Text>
|
||||
{(members.length > 0 && members[0] == props.from) && (
|
||||
<VerifiedBadge size={18} color={'gold'} verified={3}></VerifiedBadge>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
{props.attachments.length > 0 &&
|
||||
<Box ml={props.avatar_no_render ? 50 : undefined}>
|
||||
<MessageAttachments parent={props} chacha_key_plain={props.chacha_key_plain} text={props.message.trim()} key={props.message_id} timestamp={props.timestamp} delivered={props.delivered} attachments={props.attachments}></MessageAttachments>
|
||||
</Box>
|
||||
}
|
||||
<Box style={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
userSelect: 'text',
|
||||
fontSize: '13px',
|
||||
color: messageStyle == MessageStyle.BUBBLES ? (computedTheme == 'light' ? (props.parent?.from_me ? 'white' : 'black') : 'white') : (computedTheme == 'light' ? 'black' : 'white')
|
||||
}} ml={props.avatar_no_render ? 50 : undefined}>
|
||||
<TextParser performanceEntityLimit={ENTITY_LIMITS_TO_PARSE_IN_MESSAGE} oversizeIfTextSmallerThan={1} text={props.message.trim()}></TextParser>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Flex gap={5} justify={'end'} align={'flex-start'} style={{ flexShrink: 0, minWidth: props.replyed ? 0 : 50 }}>
|
||||
<Flex gap={5} justify={'end'} align={'center'}>
|
||||
{!isSelectionStarted() && <>
|
||||
{props.delivered == DeliveredMessageState.DELIVERED && <>
|
||||
{props.from_me && !props.readed && (
|
||||
<IconCheck color={theme.colors.gray[5]} stroke={3} size={16}></IconCheck>
|
||||
)}
|
||||
{props.from_me && props.readed && (
|
||||
<IconChecks stroke={3} size={16} color={theme.colors.blue[5]}></IconChecks>
|
||||
)}
|
||||
</>}
|
||||
{(props.delivered == DeliveredMessageState.WAITING && (isMessageDeliveredByTime(props.timestamp, props.attachments.length))) && <>
|
||||
<IconClock stroke={2} size={14} color={theme.colors.gray[5]}></IconClock>
|
||||
</>}
|
||||
{(props.delivered == DeliveredMessageState.ERROR || ((!isMessageDeliveredByTime(props.timestamp, props.attachments.length)) && props.delivered != DeliveredMessageState.DELIVERED)) && (
|
||||
<MessageError messageId={props.message_id} text={props.message}></MessageError>
|
||||
)}
|
||||
</>}
|
||||
{(isSelectionStarted() && !props.replyed && canReply()) && <>
|
||||
{isMessageSelected(messageReply) ?
|
||||
<IconCircleCheckFilled size={16} color={theme.colors.green[5]}></IconCircleCheckFilled>
|
||||
:
|
||||
<IconCircleCheck size={16} color={theme.colors.gray[5]}></IconCircleCheck>
|
||||
}
|
||||
</>}
|
||||
{(isSelectionStarted() && !canReply() && !props.replyed) && <IconCircleX size={16} color={theme.colors.red[5]}></IconCircleX>}
|
||||
{(showTimeInReplyMessages || !props.replyed) && <Text style={{
|
||||
userSelect: 'none'
|
||||
}} fz={12} c={colors.chevrons.active} w={30}>{
|
||||
new Date(props.timestamp).toLocaleTimeString('en-GB', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}</Text>}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)}
|
||||
{computedMessageStyle == MessageStyle.BUBBLES && (() => {
|
||||
const hasOnlyAttachments = props.attachments.length > 0 && props.message.trim().length === 0;
|
||||
|
||||
return (
|
||||
<Flex direction={props.from_me ? 'row-reverse' : 'row'} gap={'sm'} align={'flex-end'}>
|
||||
{(md && props.is_last_message_in_stack) && (
|
||||
<Avatar onClick={navigateToUserProfile} src={avatars.length > 0 ? avatars[0].avatar : undefined} name={user.title} color="initials" variant={wallpaper != '' ? 'filled' : 'light'} style={{ flexShrink: 0 }}></Avatar>
|
||||
)}
|
||||
{(md && !props.is_last_message_in_stack) && (
|
||||
<Box style={{ width: 40, height: 40, flexShrink: 0 }}></Box>
|
||||
)}
|
||||
<Flex direction={'column'} align={props.from_me ? 'flex-end' : 'flex-start'} gap={4}>
|
||||
{(!props.avatar_no_render && dialog.includes("#group") && wallpaper == '') && (
|
||||
<Flex direction={'row'} ml={2} gap={3} align={'center'}>
|
||||
<Text style={{
|
||||
userSelect: 'none'
|
||||
}} size={'sm'} onClick={navigateToUserProfile} c={getInitialsColor(user.title)} fw={500}>{user.title}</Text>
|
||||
{(members.length > 0 && members[0] == props.from) && (
|
||||
<VerifiedBadge size={18} color={'gold'} verified={3}></VerifiedBadge>
|
||||
)}
|
||||
</Flex>
|
||||
)}
|
||||
<Box style={{
|
||||
backgroundColor: hasOnlyAttachments ? 'transparent' : (props.from_me
|
||||
? (computedTheme == 'light' ? theme.colors.blue[6] : theme.colors.blue[9])
|
||||
: (computedTheme == 'light' ? (wallpaper != '' ? 'white' : theme.colors.gray[3]) : theme.colors.dark[6])),
|
||||
borderRadius: props.avatar_no_render ? '8px' : '12px',
|
||||
//borderTopLeftRadius: (!props.from_me && props.avatar_no_render) ? '2px' : undefined,
|
||||
//borderTopRightRadius: (props.from_me && props.avatar_no_render) ? '2px' : undefined,
|
||||
padding: hasOnlyAttachments ? 0 : '8px 12px',
|
||||
maxWidth: md ? '500px' : '280px',
|
||||
position: 'relative',
|
||||
marginLeft: !props.from_me && props.is_last_message_in_stack ? 2 : 0
|
||||
}}>
|
||||
{props.attachments.length > 0 &&
|
||||
<Box mb={props.message.trim().length > 0 ? 4 : 0}>
|
||||
<MessageAttachments parent={props} chacha_key_plain={props.chacha_key_plain} text={props.message.trim()} key={props.message_id} timestamp={props.timestamp} delivered={props.delivered} attachments={props.attachments}></MessageAttachments>
|
||||
</Box>
|
||||
}
|
||||
{props.message.trim().length > 0 && (
|
||||
<Box style={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
userSelect: 'text',
|
||||
fontSize: '14px',
|
||||
color: props.from_me ? 'white' : (computedTheme == 'light' ? 'black' : 'white')
|
||||
}}>
|
||||
<TextParser __reserved_2 performanceEntityLimit={ENTITY_LIMITS_TO_PARSE_IN_MESSAGE} oversizeIfTextSmallerThan={1} text={props.message.trim()}></TextParser>
|
||||
</Box>
|
||||
)}
|
||||
<Flex gap={5} justify={'flex-end'} align={'center'} mt={hasOnlyAttachments ? 0 : 4} style={{
|
||||
...(hasOnlyAttachments && props.attachments.find(a => a.type != AttachmentType.MESSAGES) ? {
|
||||
position: 'absolute',
|
||||
bottom: 8,
|
||||
right: 8,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
padding: '3px 8px',
|
||||
borderRadius: '10px',
|
||||
backdropFilter: 'blur(4px)'
|
||||
} : {})
|
||||
}}>
|
||||
{wallpaper != '' && dialog.includes("#group") && (
|
||||
<Text style={{
|
||||
userSelect: 'none',
|
||||
color: hasOnlyAttachments ? 'white' : (props.from_me ? (computedTheme == 'light' ? theme.colors.blue[2] : theme.colors.blue[4]) : (computedTheme == 'light' ? theme.colors.gray[6] : theme.colors.gray[4]))
|
||||
}} fz={11} mr={4}>
|
||||
{user.title}
|
||||
</Text>
|
||||
)}
|
||||
{!isSelectionStarted() && <>
|
||||
{props.delivered == DeliveredMessageState.DELIVERED && <>
|
||||
{props.from_me && !props.readed && (
|
||||
<IconCheck color={hasOnlyAttachments ? 'white' : theme.colors.gray[5]} stroke={3} size={14}></IconCheck>
|
||||
)}
|
||||
{props.from_me && props.readed && (
|
||||
<IconChecks stroke={3} size={14} color={hasOnlyAttachments ? 'white' : theme.colors.blue[2]}></IconChecks>
|
||||
)}
|
||||
</>}
|
||||
{(props.delivered == DeliveredMessageState.WAITING && (isMessageDeliveredByTime(props.timestamp, props.attachments.length))) && <>
|
||||
<IconClock stroke={2} size={12} color={hasOnlyAttachments ? 'white' : theme.colors.gray[5]}></IconClock>
|
||||
</>}
|
||||
{(props.delivered == DeliveredMessageState.ERROR || ((!isMessageDeliveredByTime(props.timestamp, props.attachments.length)) && props.delivered != DeliveredMessageState.DELIVERED)) && (
|
||||
<MessageError messageId={props.message_id} text={props.message}></MessageError>
|
||||
)}
|
||||
</>}
|
||||
{(isSelectionStarted() && !props.replyed && canReply()) && <>
|
||||
{isMessageSelected(messageReply) ?
|
||||
<IconCircleCheckFilled size={14} color={hasOnlyAttachments ? 'white' : theme.colors.green[5]}></IconCircleCheckFilled>
|
||||
:
|
||||
<IconCircleCheck size={14} color={hasOnlyAttachments ? 'white' : theme.colors.gray[5]}></IconCircleCheck>
|
||||
}
|
||||
</>}
|
||||
{(isSelectionStarted() && !canReply() && !props.replyed) && <IconCircleX size={14} color={hasOnlyAttachments ? 'white' : theme.colors.red[5]}></IconCircleX>}
|
||||
{(showTimeInReplyMessages || !props.replyed) && <Text style={{
|
||||
userSelect: 'none',
|
||||
color: hasOnlyAttachments ? 'white' : (props.from_me ? (computedTheme == 'light' ? theme.colors.blue[2] : theme.colors.blue[4]) : (computedTheme == 'light' ? theme.colors.gray[6] : theme.colors.gray[4]))
|
||||
}} fz={11}>
|
||||
{new Date(props.timestamp).toLocaleTimeString('en-GB', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</Text>}
|
||||
</Flex>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
})()}
|
||||
</Box>
|
||||
</>);
|
||||
}
|
||||
286
app/components/Messages/Messages.tsx
Normal file
286
app/components/Messages/Messages.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user