Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions components/Chatbot/hooks/useHelloIntegration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { MessageContext } from '@/components/Interface-Chatbot/InterfaceChatbot'
import { MESSAGE_TYPES } from '@/components/Interface-Chatbot/Messages/MessageType';
import { getAllChannels, getHelloChatHistoryApi, sendMessageToHelloApi } from '@/config/helloApi';
import socketManager from '@/hooks/socketManager';
import { store } from '@/store';
import { setDataInAppInfoReducer } from '@/store/appInfo/appInfoSlice';
import { setData, setHelloEventMessage, setImages, setInitialMessages, setOpenHelloForm, setPaginateMessages } from '@/store/chat/chatSlice';
import { setChannelListData, setHelloClientInfo, setHelloKeysData } from '@/store/hello/helloSlice';
Expand All @@ -26,6 +27,11 @@ interface HelloMessage {
chat_id?: string;
urls?: string[];
channel?: string;
replied_msg_id?: string;
replied_msg_content?: any;
replied_msg_type?: string;
replied_msg_sender_id?: string | null;
replied_from_name?: string | null;
}

export const useHelloContext = () => {
Expand Down Expand Up @@ -139,6 +145,82 @@ export const useGetMoreHelloChats = () => {
}, [currentChannelId, uuid, setChatsLoading, addHelloMessage, hasMoreMessages, skip, globalDispatch]);
};

const MAX_HISTORY_PAGES_TO_SEARCH = 50;

function findRepliedMessageElement(repliedMsgId: string): HTMLElement | null {
const byId = document.getElementById(`msg-${repliedMsgId}`);
if (byId) return byId;
if (typeof CSS !== 'undefined' && CSS.escape) {
return document.querySelector<HTMLElement>(`[data-msg-ids~="${CSS.escape(String(repliedMsgId))}"]`);
}
return null;
}

function waitForNextFrame(): Promise<void> {
return new Promise((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
});
}

/**
* Locates the message a reply-quote points to (by its persisted
* replied_msg_id) and scrolls to it. If the target isn't currently rendered
* (older messages not paginated in yet), it progressively fetches older
* history pages -- same API/actions as useGetMoreHelloChats -- until the
* message is found or history is exhausted.
*/
export const useScrollToRepliedMessage = () => {
const { chatSessionId, tabSessionId } = useHelloContext();
const { uuid, currentChannelId } = useReduxStateManagement({ chatSessionId, tabSessionId });

return useCallback(async (repliedMsgId: string): Promise<boolean> => {
let target = findRepliedMessageElement(repliedMsgId);
let attempts = 0;

while (!target && attempts < MAX_HISTORY_PAGES_TO_SEARCH) {
const state = store.getState();
const hasMoreMessages = state.Chat?.hasMoreMessages;
const skip = state.Chat?.skip ?? 0;

if (!hasMoreMessages || !currentChannelId || !uuid) break;

attempts++;
try {
const response = await getHelloChatHistoryApi(currentChannelId, skip);
const helloChats = response?.data?.data;
if (Array.isArray(helloChats) && helloChats.length > 0) {
store.dispatch(setPaginateMessages({ messages: helloChats }));
store.dispatch(setData({
hasMoreMessages: helloChats.length >= PAGE_SIZE.hello,
skip: skip + helloChats.length,
}));
} else {
store.dispatch(setData({ hasMoreMessages: false }));
break;
}
} catch (error) {
console.error("[useScrollToRepliedMessage] failed to fetch older messages:", error);
break;
}

await waitForNextFrame();
target = findRepliedMessageElement(repliedMsgId);
}

if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
target.classList.add('replied-msg-highlight');
setTimeout(() => target?.classList.remove('replied-msg-highlight'), 1500);
return true;
}

if (process.env.NODE_ENV !== 'production') {
console.warn(`[useScrollToRepliedMessage] could not locate message id="${repliedMsgId}" even after exhausting available history.`);
}
return false;
}, [currentChannelId, uuid]);
};

export const useFetchChannels = () => {
const dispatch = useDispatch();

Expand Down Expand Up @@ -253,6 +335,7 @@ export const useOnSendHello = () => {
replied_msg_type: replyToMessage ? (replyToMessage?.urls?.length ? MESSAGE_TYPES.ATTACHMENT : replyToMessage?.messageJson?.category ? MESSAGE_TYPES.INTERACTIVE : undefined) : (newMessage as any).replied_msg_type,
replied_msg_sender_id: replyToMessage ? (replyToMessage.is_auto_response || !replyToMessage.from_name ? 'bot' : replyToMessage.sender_id || replyToMessage.from_name) : null,
replied_from_name: replyToMessage ? replyToMessage.from_name : null,
replied_msg_id: replyToMessage ? (replyToMessage.message_id || replyToMessage.id) : (newMessage as any).replied_msg_id,
} : newMessage;
addHelloMessage(messageWithReply, workingChannelId);
}
Expand Down Expand Up @@ -418,6 +501,7 @@ export const useSendMessageToHello = ({
sender_id: "user",
...(replied_msg_type ? { replied_msg_type } : {}),
...(replied_msg_content !== undefined ? { replied_msg_content } : {}),
...(replyToMessageId ? { replied_msg_id: replyToMessageId } : {}),
};

// Always add message to chat so user can see it immediately
Expand Down
7 changes: 5 additions & 2 deletions components/Interface-Chatbot/Messages/HumanOrBotMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ import { hasMoreContent } from "@/utils/readMore";
import { linkify } from "@/utils/utilities";
import { Reply } from "lucide-react";
import Image from "next/image";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import RenderHelloAttachmentMessage from "../../Hello/RenderHelloAttachmentMessage";
import RenderHelloFeedbackMessage from "../../Hello/RenderHelloFeedbackMessage";
import RenderHelloInteractiveMessage from "../../Hello/RenderHelloInteractiveMessage";
import { useReplyContext } from "../contexts/ReplyContext";
import { MessageContext } from "../InterfaceChatbot";
import InterfaceMarkdown from "../Interface-Markdown/InterfaceMarkdown";
import "./Message.css";
import MessageTime from "./MessageTime";
Expand Down Expand Up @@ -257,11 +258,13 @@ const HumanOrBotMessageCard = React.memo(({ message, isBot = false, isLastMessag
const [showSenderTime, setShowSenderTime] = useState(isLastMessage);
const [showReplyButton, setShowReplyButton] = useState(false);
const { setReplyToMessage } = useReplyContext();
const { messageRef } = useContext(MessageContext);

const handleReplyClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
setReplyToMessage(message);
}, [message, setReplyToMessage]);
setTimeout(() => messageRef?.current?.focus(), 0);
}, [message, setReplyToMessage, messageRef]);

return (
<div
Expand Down
12 changes: 11 additions & 1 deletion components/Interface-Chatbot/Messages/Message.css
Original file line number Diff line number Diff line change
Expand Up @@ -219,4 +219,14 @@
[data-theme="dark"] {
--link-text-color: #90CAF9;
/* A light blue for dark themes */
}
}
.replied-msg-highlight {
animation: replied-msg-flash 1.4s ease-out;
border-radius: 10px;
}

@keyframes replied-msg-flash {
0% { background-color: rgba(59, 130, 246, 0.25); }
60% { background-color: rgba(59, 130, 246, 0.15); }
100% { background-color: transparent; }
}
6 changes: 5 additions & 1 deletion components/Interface-Chatbot/Messages/Message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,11 @@ function Message({ message, addMessage, prevTime, isLastMessage }: MessageProps)
}, [message, foregroundColor, primaryBgColor, addMessage]);

return (
<div className="w-full">
<div
className="w-full"
id={`msg-${message?.message_id || message?.id}`}
data-msg-ids={[message?.message_id, message?.id, ...(message?.all_ids || [])].filter(Boolean).join(' ')}
>
{message?.time && (
<DateGroup
prevTime={prevTime}
Expand Down
105 changes: 70 additions & 35 deletions components/Interface-Chatbot/Messages/RepliedMessage.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import RenderHelloAttachmentMessage from "@/components/Hello/RenderHelloAttachmentMessage";
import RenderHelloInteractiveMessage from "@/components/Hello/RenderHelloInteractiveMessage";
import RenderHelloVedioCallMessage from "@/components/Hello/RenderHelloVedioCallMessage";
import { useScrollToRepliedMessage } from "@/components/Chatbot/hooks/useHelloIntegration";
import { addUrlDataHoc } from "@/hoc/addUrlDataHoc";
import React from "react";
import React, { useState } from "react";
import InterfaceMarkdown from "../Interface-Markdown/InterfaceMarkdown";
import { MESSAGE_TYPES } from "./MessageType";

const RepliedMessage = ({ chatSessionId, message }: { chatSessionId: string; message: any }) => {
const scrollToRepliedMessage = useScrollToRepliedMessage();
const [isSearching, setIsSearching] = useState(false);

if (message?.replied_msg_type !== 'interactive' &&
!message?.replied_msg_content?.text &&
!(message?.replied_msg_content?.attachment && message?.replied_msg_content?.attachment?.length > 0)) {
Expand All @@ -19,6 +23,29 @@ const RepliedMessage = ({ chatSessionId, message }: { chatSessionId: string; mes
? getSenderNameFromId(senderId)
: senderId ? "" : 'You');

const repliedMsgId = message?.replied_msg_id;

const handleScrollToOriginal = async (e: React.SyntheticEvent) => {
e.stopPropagation();
if (!repliedMsgId) {
if (process.env.NODE_ENV !== 'production') {
console.warn('[RepliedMessage] no replied_msg_id on message, cannot scroll. Raw message:', message);
}
return;
}
if (isSearching) return;

setIsSearching(true);
try {
// Tries the DOM first; if the original message isn't loaded yet
// (older history not paginated in), this fetches older pages
// until it's found or history is exhausted.
await scrollToRepliedMessage(repliedMsgId);
} finally {
setIsSearching(false);
}
};

function getSenderNameFromId(id: string) {
switch (id?.toLowerCase()) {
case "user":
Expand Down Expand Up @@ -51,44 +78,52 @@ const RepliedMessage = ({ chatSessionId, message }: { chatSessionId: string; mes
}

return (
<div className={`pointer-events-none mb-1 p-2 rounded-md border-l-2 not-prose ${message?.role !== 'user' ? 'bg-gray-200 dark:bg-gray-800 border-blue-400' : 'bg-black bg-opacity-10 border-white'}`}>
<div
onClick={handleScrollToOriginal}
role={repliedMsgId ? 'button' : undefined}
tabIndex={repliedMsgId ? 0 : undefined}
onKeyDown={repliedMsgId ? (e) => { if (e.key === 'Enter' || e.key === ' ') handleScrollToOriginal(e); } : undefined}
className={`${repliedMsgId ? 'cursor-pointer hover:brightness-95 transition' : ''} ${isSearching ? 'opacity-60' : ''} mb-1 p-2 rounded-md border-l-2 not-prose ${message?.role !== 'user' ? 'bg-gray-200 dark:bg-gray-800 border-blue-400' : 'bg-black bg-opacity-10 border-white'}`}
>
<div className={`text-xs text-gray-600 mb-1 font-medium ${message?.role !== 'user' ? 'dark:text-gray-200' : 'text-inherit'}`}>{senderName}</div>
{message.replied_msg_type === MESSAGE_TYPES.INTERACTIVE ? (
<RenderHelloInteractiveMessage message={{ messageJson: message.replied_msg_content }} />
) :
message.replied_msg_type === MESSAGE_TYPES.ATTACHMENT || message.replied_msg_type === MESSAGE_TYPES.TEXT_ATTACHMENT ? (
<RenderHelloAttachmentMessage message={{ messageJson: message.replied_msg_content }} isBot={message.replied_msg_sender_id || false} />
) : message.replied_msg_type === MESSAGE_TYPES.VIDEO_CALL ? (<RenderHelloVedioCallMessage message={{ messageJson: message.replied_msg_content }} />)
: (
<div className={`text-sm text-gray-700 ${message?.role !== 'user' ? 'dark:text-gray-200' : 'text-inherit'}`}>
{(() => {
const isBotMessage = senderId && typeof senderId === 'string' && senderId.toLowerCase() !== 'user';
const replyContent = (() => {
if (typeof message.replied_msg_content === 'string') {
return message.replied_msg_content;
}
const replyText = message.replied_msg_content?.text || '';
const hasAttachment = message.replied_msg_content?.attachment &&
message.replied_msg_content.attachment.length > 0;
<div className="pointer-events-none">
{message.replied_msg_type === MESSAGE_TYPES.INTERACTIVE ? (
<RenderHelloInteractiveMessage message={{ messageJson: message.replied_msg_content }} />
) :
message.replied_msg_type === MESSAGE_TYPES.ATTACHMENT || message.replied_msg_type === MESSAGE_TYPES.TEXT_ATTACHMENT ? (
<RenderHelloAttachmentMessage message={{ messageJson: message.replied_msg_content }} isBot={message.replied_msg_sender_id || false} />
) : message.replied_msg_type === MESSAGE_TYPES.VIDEO_CALL ? (<RenderHelloVedioCallMessage message={{ messageJson: message.replied_msg_content }} />)
: (
<div className={`text-sm text-gray-700 ${message?.role !== 'user' ? 'dark:text-gray-200' : 'text-inherit'}`}>
{(() => {
const isBotMessage = senderId && typeof senderId === 'string' && senderId.toLowerCase() !== 'user';
const replyContent = (() => {
if (typeof message.replied_msg_content === 'string') {
return message.replied_msg_content;
}
const replyText = message.replied_msg_content?.text || '';
const hasAttachment = message.replied_msg_content?.attachment &&
message.replied_msg_content.attachment.length > 0;

if (replyText.trim()) {
return replyText;
}
if (hasAttachment) {
return "Attachment";
}
return "Message";
})();
if (replyText.trim()) {
return replyText;
}
if (hasAttachment) {
return "Attachment";
}
return "Message";
})();

if (isBotMessage) {
return <InterfaceMarkdown>{replyContent}</InterfaceMarkdown>;
}
return <div dangerouslySetInnerHTML={{ __html: replyContent }}></div>;
})()}
</div>
)}
if (isBotMessage) {
return <InterfaceMarkdown>{replyContent}</InterfaceMarkdown>;
}
return <div dangerouslySetInnerHTML={{ __html: replyContent }}></div>;
})()}
</div>
)}
</div>
</div>
);
}

export default React.memo(addUrlDataHoc(RepliedMessage, []));
export default React.memo(addUrlDataHoc(RepliedMessage, []));
7 changes: 5 additions & 2 deletions components/Interface-Chatbot/Messages/UserMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import { emitEventToParent } from '@/utils/emitEventsToParent/emitEventsToParent
import { ALLOWED_EVENTS_TO_SUBSCRIBE } from '@/utils/enums';
import { hasMoreContent } from '@/utils/readMore';
import { Reply } from "lucide-react";
import React, { useCallback, useState } from 'react';
import React, { useCallback, useContext, useState } from 'react';
import { useReplyContext } from "../contexts/ReplyContext";
import { MessageContext } from "../InterfaceChatbot";
import ImageWithFallback from './ImageWithFallback';
import "./Message.css";
import MessageTime from './MessageTime';
Expand All @@ -22,6 +23,7 @@ const UserMessageCard = React.memo(({ message, backgroundColor, foregroundColor,
const [showSenderTime, setShowSenderTime] = useState(false);
const [showReplyButton, setShowReplyButton] = useState(false);
const { setReplyToMessage } = useReplyContext();
const { messageRef } = useContext(MessageContext);

const { sendEventToParentOnMessageClick } = useCustomSelector((state) => ({
sendEventToParentOnMessageClick: state.Interface?.[chatSessionId]?.eventsSubscribedByParent?.includes(ALLOWED_EVENTS_TO_SUBSCRIBE.MESSAGE_CLICK) || false
Expand All @@ -43,7 +45,8 @@ const UserMessageCard = React.memo(({ message, backgroundColor, foregroundColor,
is_auto_response: false,
message_id: message?.message_id || message?.id
});
}, [message, setReplyToMessage]);
setTimeout(() => messageRef?.current?.focus(), 0);
}, [message, setReplyToMessage, messageRef]);

return (
<div
Expand Down
Loading