MessageList.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. "use client";
  2. import { useEffect, useRef } from "react";
  3. import { MessageItem } from "@/features/agent/types";
  4. import { formatMessageTime } from "@/utils/format";
  5. import { highlightText } from "@/utils/highlight";
  6. interface MessageListProps {
  7. messages: MessageItem[];
  8. loading: boolean;
  9. highlightKeyword: string;
  10. onHighlightClear: () => void;
  11. currentUserIsAgent?: boolean;
  12. disableAutoScroll?: boolean;
  13. conversationId?: number | null;
  14. onMarkMessagesRead?: (conversationId: number, readerIsAgent: boolean) => void;
  15. }
  16. export function MessageList({
  17. messages,
  18. loading,
  19. highlightKeyword,
  20. onHighlightClear,
  21. currentUserIsAgent = true,
  22. disableAutoScroll = false,
  23. conversationId = null,
  24. onMarkMessagesRead,
  25. }: MessageListProps) {
  26. const containerRef = useRef<HTMLDivElement>(null);
  27. const messageRefs = useRef<Record<number, HTMLDivElement | null>>({});
  28. const shouldStickToBottomRef = useRef(true);
  29. const lastConversationIdRef = useRef<number | null>(null);
  30. const markReadTimerRef = useRef<NodeJS.Timeout | null>(null);
  31. const lastMarkedReadRef = useRef<number>(0);
  32. const lastMessageIdRef = useRef<number | null>(null);
  33. const lastMessageCountRef = useRef<number>(0);
  34. useEffect(() => {
  35. if (conversationId !== lastConversationIdRef.current) {
  36. lastConversationIdRef.current = conversationId;
  37. shouldStickToBottomRef.current = true;
  38. lastMessageIdRef.current = null;
  39. lastMessageCountRef.current = 0;
  40. }
  41. }, [conversationId]);
  42. // 监听滚动事件,当滚动到底部附近时标记消息为已读
  43. // 注意:即使 disableAutoScroll 为 true,也应该允许通过滚动来标记消息为已读
  44. useEffect(() => {
  45. const container = containerRef.current;
  46. if (!container || !conversationId || !onMarkMessagesRead) {
  47. return;
  48. }
  49. const handleScroll = () => {
  50. const { scrollTop, scrollHeight, clientHeight } = container;
  51. const distanceToBottom = scrollHeight - scrollTop - clientHeight;
  52. const isNearBottom = distanceToBottom < 100;
  53. shouldStickToBottomRef.current = isNearBottom;
  54. // 当滚动到底部附近时,检查是否有未读消息需要标记为已读
  55. if (isNearBottom) {
  56. // 防抖:延迟 500ms 后标记为已读,避免频繁调用
  57. if (markReadTimerRef.current) {
  58. clearTimeout(markReadTimerRef.current);
  59. }
  60. markReadTimerRef.current = setTimeout(() => {
  61. // 检查是否有未读的消息(对方发送的消息)
  62. const unreadMessages = messages.filter((msg) => {
  63. // 对于客服端:检查访客发送的未读消息
  64. // 对于访客端:检查客服发送的未读消息
  65. const isFromOther = currentUserIsAgent
  66. ? !msg.sender_is_agent
  67. : msg.sender_is_agent;
  68. return isFromOther && !msg.is_read;
  69. });
  70. if (unreadMessages.length > 0) {
  71. // 避免频繁调用:如果距离上次标记不到 2 秒,则跳过
  72. const now = Date.now();
  73. if (now - lastMarkedReadRef.current < 2000) {
  74. return;
  75. }
  76. // 标记为已读
  77. onMarkMessagesRead(conversationId, currentUserIsAgent);
  78. lastMarkedReadRef.current = now;
  79. }
  80. }, 500);
  81. }
  82. };
  83. handleScroll();
  84. container.addEventListener("scroll", handleScroll);
  85. return () => {
  86. container.removeEventListener("scroll", handleScroll);
  87. if (markReadTimerRef.current) {
  88. clearTimeout(markReadTimerRef.current);
  89. }
  90. };
  91. }, [conversationId, onMarkMessagesRead, messages, currentUserIsAgent]);
  92. useEffect(() => {
  93. if (messages.length === 0) {
  94. return;
  95. }
  96. const container = containerRef.current;
  97. if (!container) {
  98. return;
  99. }
  100. const keyword = highlightKeyword.trim();
  101. const lastMessage = messages[messages.length - 1];
  102. const isLastMessageFromCurrentUser = lastMessage
  103. ? currentUserIsAgent
  104. ? lastMessage.sender_is_agent
  105. : !lastMessage.sender_is_agent
  106. : false;
  107. // 检查是否有新消息(通过比较消息ID或消息数量)
  108. const hasNewMessage =
  109. lastMessage.id !== lastMessageIdRef.current ||
  110. messages.length !== lastMessageCountRef.current;
  111. // 更新记录
  112. lastMessageIdRef.current = lastMessage.id;
  113. lastMessageCountRef.current = messages.length;
  114. // 使用 requestAnimationFrame 确保 DOM 已更新后再检查位置
  115. requestAnimationFrame(() => {
  116. // 重新获取容器引用,确保使用最新的 DOM 元素
  117. const currentContainer = containerRef.current;
  118. if (!currentContainer) {
  119. return;
  120. }
  121. // 在 DOM 更新后检查当前位置
  122. const { scrollTop, scrollHeight, clientHeight } = currentContainer;
  123. const distanceToBottom = scrollHeight - scrollTop - clientHeight;
  124. const isNearBottom = distanceToBottom < 100;
  125. // 更新 shouldStickToBottomRef,确保使用最新的位置信息
  126. shouldStickToBottomRef.current = isNearBottom;
  127. // 滚动逻辑:
  128. // 1. 如果最后一条消息是自己发送的,无论在哪里都自动滚动到底部(即使 disableAutoScroll 为 true)
  129. // 2. 如果最后一条消息是对方发送的:
  130. // - 如果用户在底部附近(isNearBottom),无论 disableAutoScroll 是什么值,都自动滚动到底部(保持"粘到底部"的行为)
  131. // - 如果用户不在底部附近,且 disableAutoScroll 为 true,不自动滚动(用于查看历史消息时不被新消息打断)
  132. // - 如果用户不在底部附近,且 disableAutoScroll 为 false,不自动滚动(与上面的行为一致)
  133. // 3. 如果没有新消息(例如只是消息状态更新),不改变滚动位置
  134. // 这样确保访客端和客服端的行为一致:当用户在底部附近时,收到新消息会自动滚动到底部
  135. const shouldAutoScroll =
  136. hasNewMessage &&
  137. (isLastMessageFromCurrentUser || isNearBottom);
  138. if (keyword) {
  139. const keywordLower = keyword.toLowerCase();
  140. const matchingMessage = messages.find((message) =>
  141. message.content.toLowerCase().includes(keywordLower)
  142. );
  143. if (matchingMessage) {
  144. const scroll = () => {
  145. const target = messageRefs.current[matchingMessage.id];
  146. if (target) {
  147. target.scrollIntoView({
  148. behavior: "smooth",
  149. block: "center",
  150. inline: "nearest",
  151. });
  152. }
  153. setTimeout(onHighlightClear, 3000);
  154. };
  155. setTimeout(scroll, 200);
  156. } else {
  157. if (!shouldAutoScroll) {
  158. return;
  159. }
  160. const scrollBottom = () => {
  161. const container = containerRef.current;
  162. if (!container) {
  163. return;
  164. }
  165. container.scrollTo({
  166. top: container.scrollHeight,
  167. behavior: "smooth",
  168. });
  169. };
  170. setTimeout(scrollBottom, 100);
  171. onHighlightClear();
  172. }
  173. } else {
  174. if (!shouldAutoScroll) {
  175. return;
  176. }
  177. const scrollBottom = () => {
  178. const container = containerRef.current;
  179. if (!container) {
  180. return;
  181. }
  182. container.scrollTo({
  183. top: container.scrollHeight,
  184. behavior: "smooth",
  185. });
  186. };
  187. setTimeout(scrollBottom, 100);
  188. }
  189. // 当消息列表更新且自动滚动到底部时,检查是否需要标记为已读
  190. // 或者如果用户已经在底部附近,也应该标记为已读(即使没有自动滚动)
  191. if (conversationId && onMarkMessagesRead && messages.length > 0) {
  192. // 延迟标记为已读,确保滚动动画完成
  193. if (markReadTimerRef.current) {
  194. clearTimeout(markReadTimerRef.current);
  195. }
  196. markReadTimerRef.current = setTimeout(() => {
  197. // 如果自动滚动到底部,或者用户已经在底部附近,都标记为已读
  198. const shouldMarkRead = shouldAutoScroll || isNearBottom;
  199. if (!shouldMarkRead) {
  200. return;
  201. }
  202. const unreadMessages = messages.filter((msg) => {
  203. const isFromOther = currentUserIsAgent
  204. ? !msg.sender_is_agent
  205. : msg.sender_is_agent;
  206. return isFromOther && !msg.is_read;
  207. });
  208. if (unreadMessages.length > 0) {
  209. // 避免频繁调用:如果距离上次标记不到 2 秒,则跳过
  210. const now = Date.now();
  211. if (now - lastMarkedReadRef.current < 2000) {
  212. return;
  213. }
  214. onMarkMessagesRead(conversationId, currentUserIsAgent);
  215. lastMarkedReadRef.current = now;
  216. }
  217. }, shouldAutoScroll ? 800 : 300); // 如果自动滚动,等待 800ms;否则等待 300ms
  218. }
  219. });
  220. }, [
  221. messages,
  222. highlightKeyword,
  223. onHighlightClear,
  224. disableAutoScroll,
  225. currentUserIsAgent,
  226. conversationId,
  227. onMarkMessagesRead,
  228. ]);
  229. if (loading) {
  230. return (
  231. <div className="flex-1 flex items-center justify-center bg-gray-50">
  232. <span className="text-sm text-gray-500">消息加载中...</span>
  233. </div>
  234. );
  235. }
  236. if (messages.length === 0) {
  237. return (
  238. <div ref={containerRef} className="flex-1 overflow-y-auto p-4 bg-gray-50">
  239. <div className="text-center text-gray-400 mt-8 text-sm">暂无消息</div>
  240. </div>
  241. );
  242. }
  243. return (
  244. <div
  245. ref={containerRef}
  246. className="flex-1 overflow-y-auto p-4 bg-gray-50"
  247. >
  248. <div className="space-y-4">
  249. {messages.map((message) => {
  250. const keyword = highlightKeyword.trim();
  251. const isMatching =
  252. keyword !== "" &&
  253. message.content.toLowerCase().includes(keyword.toLowerCase());
  254. const bubbleContent =
  255. keyword !== "" && isMatching
  256. ? highlightText(message.content, keyword)
  257. : message.content;
  258. if (message.message_type === "system_message") {
  259. return (
  260. <div
  261. key={message.id}
  262. ref={(element) => {
  263. messageRefs.current[message.id] = element;
  264. }}
  265. className={`text-center text-xs text-gray-500`}
  266. >
  267. <span className="inline-block px-3 py-1 rounded-full bg-gray-200 text-gray-700">
  268. {message.content}
  269. </span>
  270. </div>
  271. );
  272. }
  273. const isSenderAgent = message.sender_is_agent;
  274. const isCurrentUser = currentUserIsAgent
  275. ? isSenderAgent
  276. : !isSenderAgent;
  277. const alignment = isCurrentUser ? "justify-end" : "justify-start";
  278. const bubbleColor = isCurrentUser
  279. ? "bg-blue-500 text-white"
  280. : "bg-white text-gray-800 border border-gray-200";
  281. const cornerClass = isCurrentUser ? "rounded-br-none" : "rounded-bl-none";
  282. const receiptClass = isCurrentUser
  283. ? message.is_read
  284. ? currentUserIsAgent
  285. ? "text-blue-400"
  286. : "text-blue-200"
  287. : currentUserIsAgent
  288. ? ""
  289. : "text-blue-200"
  290. : "";
  291. return (
  292. <div
  293. key={message.id}
  294. ref={(element) => {
  295. messageRefs.current[message.id] = element;
  296. }}
  297. className={`flex ${alignment}`}
  298. >
  299. <div className="max-w-[70%]">
  300. <div
  301. className={`px-4 py-2 rounded-2xl shadow-sm ${
  302. cornerClass
  303. } ${bubbleColor}`}
  304. >
  305. <div className="whitespace-pre-wrap break-words text-sm">
  306. {bubbleContent}
  307. </div>
  308. </div>
  309. <div className="flex items-center gap-1 mt-1 text-[10px] text-gray-400">
  310. {isCurrentUser && (
  311. <span className={receiptClass}>
  312. {message.is_read ? "✓✓" : "✓"}
  313. </span>
  314. )}
  315. <span>{formatMessageTime(message.created_at)}</span>
  316. </div>
  317. </div>
  318. </div>
  319. );
  320. })}
  321. </div>
  322. </div>
  323. );
  324. }