From d06266fe043813c5b870a4c0deba6976c602f5eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Thu, 26 Feb 2026 00:09:17 +0100 Subject: [PATCH] nicolium: make chat list hotkey-navigable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- .../pl-fe/src/components/ui/icon-button.tsx | 4 +- .../chats/components/chat-list-item.tsx | 276 ++++++++++-------- .../chats/components/chat-list-shoutbox.tsx | 91 +++--- .../features/chats/components/chat-list.tsx | 43 ++- 4 files changed, 245 insertions(+), 169 deletions(-) diff --git a/packages/pl-fe/src/components/ui/icon-button.tsx b/packages/pl-fe/src/components/ui/icon-button.tsx index d35c90b79..56c5f5dbc 100644 --- a/packages/pl-fe/src/components/ui/icon-button.tsx +++ b/packages/pl-fe/src/components/ui/icon-button.tsx @@ -20,8 +20,8 @@ interface IIconButton extends React.ButtonHTMLAttributes { } /** A clickable icon. */ -const IconButton: React.FC = React.forwardRef( - (props, ref: React.ForwardedRef): React.JSX.Element => { +const IconButton = React.forwardRef( + (props: IIconButton, ref: React.ForwardedRef): React.JSX.Element => { const { src, className, iconClassName, text, theme = 'seamless', ...filteredProps } = props; const Component = (props.href ? 'a' : 'button') as 'button'; diff --git a/packages/pl-fe/src/features/chats/components/chat-list-item.tsx b/packages/pl-fe/src/features/chats/components/chat-list-item.tsx index 83e84ad13..b3ac346b8 100644 --- a/packages/pl-fe/src/features/chats/components/chat-list-item.tsx +++ b/packages/pl-fe/src/features/chats/components/chat-list-item.tsx @@ -11,6 +11,7 @@ import IconButton from '@/components/ui/icon-button'; import VerificationBadge from '@/components/verification-badge'; import { useChatContext } from '@/contexts/chat-context'; import Emojify from '@/features/emoji/emojify'; +import { Hotkeys } from '@/features/ui/components/hotkeys'; import { useFeatures } from '@/hooks/use-features'; import { useRelationshipQuery } from '@/queries/accounts/use-relationship'; import { useDeleteChat } from '@/queries/chats'; @@ -34,146 +35,169 @@ const messages = defineMessages({ interface IChatListItem { chat: Chat; onClick: (chat: Chat) => void; + onMoveUp?: (chatId: string) => void; + onMoveDown?: (chatId: string) => void; } -const ChatListItem: React.FC = React.memo(({ chat, onClick }) => { - const { openModal } = useModalsActions(); - const intl = useIntl(); - const features = useFeatures(); - const navigate = useNavigate(); +const ChatListItem: React.FC = React.memo( + ({ chat, onClick, onMoveUp, onMoveDown }) => { + const { openModal } = useModalsActions(); + const intl = useIntl(); + const features = useFeatures(); + const navigate = useNavigate(); - const { isUsingMainChatPage } = useChatContext(); - const deleteChat = useDeleteChat(chat?.id); - const { data: relationship } = useRelationshipQuery(chat?.account.id); + const { isUsingMainChatPage } = useChatContext(); + const deleteChat = useDeleteChat(chat?.id); + const { data: relationship } = useRelationshipQuery(chat?.account.id); - const isBlocked = relationship?.blocked_by && false; - const isBlocking = relationship?.blocking && false; + const isBlocked = relationship?.blocked_by && false; + const isBlocking = relationship?.blocking && false; - const menu = useMemo( - (): Menu => [ - { - text: intl.formatMessage(messages.leaveChat), - action: (event) => { - event.stopPropagation(); + const menu = useMemo( + (): Menu => [ + { + text: intl.formatMessage(messages.leaveChat), + action: (event) => { + event.stopPropagation(); - openModal('CONFIRM', { - heading: intl.formatMessage(messages.leaveHeading), - message: intl.formatMessage(messages.leaveMessage), - confirm: intl.formatMessage(messages.leaveConfirm), - onConfirm: () => { - deleteChat.mutate(undefined, { - onSuccess() { - if (isUsingMainChatPage) { - navigate({ to: '/chats' }); - } - }, - }); - }, - }); + openModal('CONFIRM', { + heading: intl.formatMessage(messages.leaveHeading), + message: intl.formatMessage(messages.leaveMessage), + confirm: intl.formatMessage(messages.leaveConfirm), + onConfirm: () => { + deleteChat.mutate(undefined, { + onSuccess() { + if (isUsingMainChatPage) { + navigate({ to: '/chats' }); + } + }, + }); + }, + }); + }, + icon: require('@phosphor-icons/core/regular/sign-out.svg'), }, - icon: require('@phosphor-icons/core/regular/sign-out.svg'), - }, - ], - [], - ); + ], + [], + ); - const handleKeyDown: React.KeyboardEventHandler = (event) => { - if (event.key === 'Enter' || event.key === ' ') { - onClick(chat); - } - }; - - return ( -
{ + const handleKeyDown: React.KeyboardEventHandler = (event) => { + if (event.key === 'Enter' || event.key === ' ') { onClick(chat); - }} - onKeyDown={handleKeyDown} - className='⁂-chat-list-item' - data-testid='chat-list-item' - tabIndex={0} - > -
-
- + } + }; -
-
-

- -

- {chat.account?.verified && } + const handleMoveUp = () => { + if (onMoveUp) { + onMoveUp(chat.id); + } + }; + + const handleMoveDown = () => { + if (onMoveDown) { + onMoveDown(chat.id); + } + }; + + const handlers = { + moveUp: handleMoveUp, + moveDown: handleMoveDown, + }; + + return ( + { + onClick(chat); + }} + onKeyDown={handleKeyDown} + > +
+
+
+ + +
+
+

+ +

+ {chat.account?.verified && } +
+ +

+ {isBlocked ? ( + + ) : isBlocking ? ( + + ) : ( + chat.last_message?.content && ( + + ) + )} +

+
-

- {isBlocked ? ( - - ) : isBlocking ? ( - - ) : ( - chat.last_message?.content && ( - - ) +

+ {features.chatsDelete && ( +
+ + + +
)} -

+ + {chat.last_message && ( + <> + {chat.last_message.unread && ( +
+ )} + + + + )} +
- -
- {features.chatsDelete && ( -
- - - -
- )} - - {chat.last_message && ( - <> - {chat.last_message.unread && ( -
- )} - - - - )} -
-
-
- ); -}); +
+ ); + }, +); export { ChatListItem as default }; diff --git a/packages/pl-fe/src/features/chats/components/chat-list-shoutbox.tsx b/packages/pl-fe/src/features/chats/components/chat-list-shoutbox.tsx index 32e6a063d..df074eae2 100644 --- a/packages/pl-fe/src/features/chats/components/chat-list-shoutbox.tsx +++ b/packages/pl-fe/src/features/chats/components/chat-list-shoutbox.tsx @@ -4,6 +4,7 @@ import { FormattedMessage } from 'react-intl'; import { ParsedContent } from '@/components/parsed-content'; import Avatar from '@/components/ui/avatar'; import Emojify from '@/features/emoji/emojify'; +import { Hotkeys } from '@/features/ui/components/hotkeys'; import { useFrontendConfig } from '@/hooks/use-frontend-config'; import { useInstance } from '@/hooks/use-instance'; import { useAccount } from '@/queries/accounts/use-account'; @@ -13,9 +14,11 @@ import type { Chat } from 'pl-api'; interface IChatListShoutbox { onClick: (chat: Chat | 'shoutbox') => void; + onMoveUp?: (chatId: string) => void; + onMoveDown?: (chatId: string) => void; } -const ChatListShoutbox: React.FC = ({ onClick }) => { +const ChatListShoutbox: React.FC = ({ onClick, onMoveUp, onMoveDown }) => { const instance = useInstance(); const { logo } = useFrontendConfig(); const messages = useShoutboxMessages(); @@ -26,53 +29,71 @@ const ChatListShoutbox: React.FC = ({ onClick }) => { } }; + const handleMoveUp = () => { + if (onMoveUp) { + onMoveUp('shoutbox'); + } + }; + + const handleMoveDown = () => { + if (onMoveDown) { + onMoveDown('shoutbox'); + } + }; + + const handlers = { + moveUp: handleMoveUp, + moveDown: handleMoveDown, + }; + const lastMessage = messages.at(-1); const { data: lastMessageAuthor } = useAccount(lastMessage?.author_id); return ( -
{ onClick('shoutbox'); }} onKeyDown={handleKeyDown} - className='⁂-chat-list-item ⁂-chat-list-item--shoutbox' - data-testid='chat-list-item' - tabIndex={0} > -
- -
-
-

- -

-
- - {lastMessage && ( - <> -

- {lastMessageAuthor && ( - - - {': '} - - )} - +

+
+ +
+
+

+

- - )} +
+ + {lastMessage && ( + <> +

+ {lastMessageAuthor && ( + + + {': '} + + )} + +

+ + )} +
-
+ ); }; diff --git a/packages/pl-fe/src/features/chats/components/chat-list.tsx b/packages/pl-fe/src/features/chats/components/chat-list.tsx index 04d1f9781..9d53c0196 100644 --- a/packages/pl-fe/src/features/chats/components/chat-list.tsx +++ b/packages/pl-fe/src/features/chats/components/chat-list.tsx @@ -1,6 +1,6 @@ import clsx from 'clsx'; -import React, { useCallback, useState } from 'react'; -import { Virtuoso } from 'react-virtuoso'; +import React, { useCallback, useRef, useState } from 'react'; +import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'; import PullToRefresh from '@/components/pull-to-refresh'; import Spinner from '@/components/ui/spinner'; @@ -8,6 +8,7 @@ import Stack from '@/components/ui/stack'; import PlaceholderChat from '@/features/placeholder/components/placeholder-chat'; import { useChats } from '@/queries/chats'; import { useShoutboxIsLoading } from '@/stores/shoutbox'; +import { selectChild } from '@/utils/scroll-utils'; import ChatListItem from './chat-list-item'; import ChatListShoutbox from './chat-list-shoutbox'; @@ -20,6 +21,8 @@ interface IChatList { } const ChatList: React.FC = ({ onClickChat, useWindowScroll = false }) => { + const node = useRef(null); + const showShoutbox = !useShoutboxIsLoading(); const { @@ -41,20 +44,47 @@ const ChatList: React.FC = ({ onClickChat, useWindowScroll = false }) const handleRefresh = () => refetch(); + const getCurrentIndex = (id: string): number => + allChats?.findIndex((key) => (key === 'shoutbox' ? key : key.id) === id) ?? -1; + + const handleMoveUp = (chatId: string) => { + const elementIndex = getCurrentIndex(chatId) - 1; + selectChild(elementIndex, node, document.querySelector('.⁂-chat-widget__list') ?? undefined); + }; + + const handleMoveDown = (chatId: string) => { + const elementIndex = getCurrentIndex(chatId) + 1; + selectChild( + elementIndex, + node, + document.querySelector('.⁂-chat-widget__list') ?? undefined, + allChats?.length, + ); + }; + const renderChatListItem = useCallback( (_index: number, chat: Chat | 'shoutbox') => { if (chat === 'shoutbox') { return (
- +
); } return ( -
- -
+ ); }, [onClickChat], @@ -83,6 +113,7 @@ const ChatList: React.FC = ({ onClickChat, useWindowScroll = false }) })} > { setNearTop(atTop); }}