From eef89983e022bc88e67a0411d5d80ef1d0d4156c Mon Sep 17 00:00:00 2001 From: benya Date: Sun, 8 Mar 2026 02:53:03 +0300 Subject: [PATCH] feat(search): focus and highlight found message in chat - store focused message id per chat - scroll to target message and highlight it after search selection - clear focus automatically after short timeout --- web/src/components/MessageList.tsx | 19 ++++++++++++++++++- web/src/pages/ChatsPage.tsx | 2 ++ web/src/store/chatStore.ts | 12 +++++++++++- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/web/src/components/MessageList.tsx b/web/src/components/MessageList.tsx index 5d77118..8b6fb54 100644 --- a/web/src/components/MessageList.tsx +++ b/web/src/components/MessageList.tsx @@ -28,6 +28,8 @@ export function MessageList() { const loadingMoreByChat = useChatStore((s) => s.loadingMoreByChat); const loadMoreMessages = useChatStore((s) => s.loadMoreMessages); const unreadBoundaryByChat = useChatStore((s) => s.unreadBoundaryByChat); + const focusedMessageIdByChat = useChatStore((s) => s.focusedMessageIdByChat); + const setFocusedMessage = useChatStore((s) => s.setFocusedMessage); const chats = useChatStore((s) => s.chats); const setReplyToMessage = useChatStore((s) => s.setReplyToMessage); const updateChatPinnedMessage = useChatStore((s) => s.updateChatPinnedMessage); @@ -64,6 +66,7 @@ export function MessageList() { }, [chats, forwardQuery]); const unreadBoundaryCount = activeChatId ? (unreadBoundaryByChat[activeChatId] ?? 0) : 0; const unreadBoundaryIndex = unreadBoundaryCount > 0 ? Math.max(0, messages.length - unreadBoundaryCount) : -1; + const focusedMessageId = activeChatId ? (focusedMessageIdByChat[activeChatId] ?? null) : null; const hasMore = Boolean(activeChatId && hasMoreByChat[activeChatId]); const isLoadingMore = Boolean(activeChatId && loadingMoreByChat[activeChatId]); const selectedMessages = useMemo( @@ -104,6 +107,19 @@ export function MessageList() { return () => window.clearInterval(interval); }, [pendingDelete]); + useEffect(() => { + if (!activeChatId || !focusedMessageId) { + return; + } + const element = document.getElementById(`message-${focusedMessageId}`); + if (!element) { + return; + } + element.scrollIntoView({ behavior: "smooth", block: "center" }); + const timer = window.setTimeout(() => setFocusedMessage(activeChatId, null), 2500); + return () => window.clearTimeout(timer); + }, [activeChatId, focusedMessageId, messages.length, setFocusedMessage]); + if (!activeChatId) { return
Select a chat
; } @@ -287,9 +303,10 @@ export function MessageList() { ) : null}
{ if (selectedIds.size > 0) { toggleSelected(message.id); diff --git a/web/src/pages/ChatsPage.tsx b/web/src/pages/ChatsPage.tsx index 317f5d4..1e60744 100644 --- a/web/src/pages/ChatsPage.tsx +++ b/web/src/pages/ChatsPage.tsx @@ -17,6 +17,7 @@ export function ChatsPage() { const activeChatId = useChatStore((s) => s.activeChatId); const chats = useChatStore((s) => s.chats); const setActiveChatId = useChatStore((s) => s.setActiveChatId); + const setFocusedMessage = useChatStore((s) => s.setFocusedMessage); const loadMessages = useChatStore((s) => s.loadMessages); const activeChat = chats.find((chat) => chat.id === activeChatId); const isReadOnlyChannel = Boolean(activeChat && activeChat.type === "channel" && activeChat.my_role === "member"); @@ -147,6 +148,7 @@ export function ChatsPage() { key={`search-msg-${message.id}`} onClick={() => { setActiveChatId(message.chat_id); + setFocusedMessage(message.chat_id, message.id); setSearchOpen(false); }} > diff --git a/web/src/store/chatStore.ts b/web/src/store/chatStore.ts index e22f902..057449e 100644 --- a/web/src/store/chatStore.ts +++ b/web/src/store/chatStore.ts @@ -12,6 +12,7 @@ interface ChatState { typingByChat: Record; replyToByChat: Record; unreadBoundaryByChat: Record; + focusedMessageIdByChat: Record; loadChats: (query?: string) => Promise; setActiveChatId: (chatId: number | null) => void; loadMessages: (chatId: number) => Promise; @@ -44,6 +45,7 @@ interface ChatState { applyPresenceEvent: (chatId: number, userId: number, isOnline: boolean, lastSeenAt?: string) => void; setDraft: (chatId: number, text: string) => void; clearDraft: (chatId: number) => void; + setFocusedMessage: (chatId: number, messageId: number | null) => void; } export const useChatStore = create((set, get) => ({ @@ -56,6 +58,7 @@ export const useChatStore = create((set, get) => ({ typingByChat: {}, replyToByChat: {}, unreadBoundaryByChat: {}, + focusedMessageIdByChat: {}, loadChats: async (query) => { const chats = await getChats(query); const currentActive = get().activeChatId; @@ -342,5 +345,12 @@ export const useChatStore = create((set, get) => ({ const next = { ...state.draftsByChat }; delete next[chatId]; return { draftsByChat: next }; - }) + }), + setFocusedMessage: (chatId, messageId) => + set((state) => ({ + focusedMessageIdByChat: { + ...state.focusedMessageIdByChat, + [chatId]: messageId + } + })) }));