nicolium: make chat list hotkey-navigable

Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
nicole mikołajczyk
2026-02-26 00:09:17 +01:00
parent ffb6af3eef
commit d06266fe04
4 changed files with 245 additions and 169 deletions

View File

@ -20,8 +20,8 @@ interface IIconButton extends React.ButtonHTMLAttributes<HTMLButtonElement> {
}
/** A clickable icon. */
const IconButton: React.FC<IIconButton> = React.forwardRef(
(props, ref: React.ForwardedRef<HTMLButtonElement>): React.JSX.Element => {
const IconButton = React.forwardRef(
(props: IIconButton, ref: React.ForwardedRef<HTMLButtonElement>): React.JSX.Element => {
const { src, className, iconClassName, text, theme = 'seamless', ...filteredProps } = props;
const Component = (props.href ? 'a' : 'button') as 'button';

View File

@ -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<IChatListItem> = React.memo(({ chat, onClick }) => {
const { openModal } = useModalsActions();
const intl = useIntl();
const features = useFeatures();
const navigate = useNavigate();
const ChatListItem: React.FC<IChatListItem> = 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<HTMLDivElement> = (event) => {
if (event.key === 'Enter' || event.key === ' ') {
onClick(chat);
}
};
return (
<div
role='button'
key={chat.id}
onClick={() => {
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (event) => {
if (event.key === 'Enter' || event.key === ' ') {
onClick(chat);
}}
onKeyDown={handleKeyDown}
className='⁂-chat-list-item'
data-testid='chat-list-item'
tabIndex={0}
>
<div>
<div className='⁂-chat-list-item__info'>
<Avatar
src={chat.account.avatar}
alt={chat.account.avatar_description}
size={40}
className='⁂-chat-list-item__avatar'
isCat={chat.account.is_cat}
username={chat.account.username}
/>
}
};
<div className='⁂-chat-list-item__content'>
<div className='⁂-chat-list-item__name'>
<p>
<Emojify text={chat.account.display_name} emojis={chat.account.emojis} />
</p>
{chat.account?.verified && <VerificationBadge />}
const handleMoveUp = () => {
if (onMoveUp) {
onMoveUp(chat.id);
}
};
const handleMoveDown = () => {
if (onMoveDown) {
onMoveDown(chat.id);
}
};
const handlers = {
moveUp: handleMoveUp,
moveDown: handleMoveDown,
};
return (
<Hotkeys
handlers={handlers}
className='px-2'
tabIndex={0}
role='button'
key={chat.id}
onClick={() => {
onClick(chat);
}}
onKeyDown={handleKeyDown}
>
<div className='⁂-chat-list-item' data-testid='chat-list-item'>
<div>
<div className='⁂-chat-list-item__info'>
<Avatar
src={chat.account.avatar}
alt={chat.account.avatar_description}
size={40}
className='⁂-chat-list-item__avatar'
isCat={chat.account.is_cat}
username={chat.account.username}
/>
<div className='⁂-chat-list-item__content'>
<div className='⁂-chat-list-item__name'>
<p>
<Emojify text={chat.account.display_name} emojis={chat.account.emojis} />
</p>
{chat.account?.verified && <VerificationBadge />}
</div>
<p
className={clsx('⁂-chat-list-item__message', {
'⁂-chat-list-item__message--unread':
!(isBlocked ?? isBlocking) && chat.last_message?.unread,
'⁂-chat-list-item__message--blocking': isBlocked ?? isBlocking,
})}
>
{isBlocked ? (
<FormattedMessage
id='chat_list_item.blocked_you'
defaultMessage='This user has blocked you'
/>
) : isBlocking ? (
<FormattedMessage
id='chat_list_item.blocking'
defaultMessage='You have blocked this user'
/>
) : (
chat.last_message?.content && (
<ParsedContent
html={chat.last_message?.content}
emojis={chat.last_message.emojis}
/>
)
)}
</p>
</div>
</div>
<p
className={clsx('⁂-chat-list-item__message', {
'⁂-chat-list-item__message--unread':
!(isBlocked ?? isBlocking) && chat.last_message?.unread,
'⁂-chat-list-item__message--blocking': isBlocked ?? isBlocking,
})}
>
{isBlocked ? (
<FormattedMessage
id='chat_list_item.blocked_you'
defaultMessage='This user has blocked you'
/>
) : isBlocking ? (
<FormattedMessage
id='chat_list_item.blocking'
defaultMessage='You have blocked this user'
/>
) : (
chat.last_message?.content && (
<ParsedContent
html={chat.last_message?.content}
emojis={chat.last_message.emojis}
/>
)
<div className='⁂-chat-list-item__actions'>
{features.chatsDelete && (
<div className='⁂-chat-list-item__menu'>
<DropdownMenu items={menu}>
<IconButton
src={require('@phosphor-icons/core/regular/dots-three.svg')}
title={intl.formatMessage(messages.settings)}
/>
</DropdownMenu>
</div>
)}
</p>
{chat.last_message && (
<>
{chat.last_message.unread && (
<div className='⁂-chat-list-item__unread' data-testid='chat-unread-indicator' />
)}
<RelativeTimestamp
timestamp={chat.last_message.created_at}
align='right'
size='xs'
theme={chat.last_message.unread ? 'default' : 'muted'}
truncate
/>
</>
)}
</div>
</div>
</div>
<div className='⁂-chat-list-item__actions'>
{features.chatsDelete && (
<div className='⁂-chat-list-item__menu'>
<DropdownMenu items={menu}>
<IconButton
src={require('@phosphor-icons/core/regular/dots-three.svg')}
title={intl.formatMessage(messages.settings)}
/>
</DropdownMenu>
</div>
)}
{chat.last_message && (
<>
{chat.last_message.unread && (
<div className='⁂-chat-list-item__unread' data-testid='chat-unread-indicator' />
)}
<RelativeTimestamp
timestamp={chat.last_message.created_at}
align='right'
size='xs'
theme={chat.last_message.unread ? 'default' : 'muted'}
truncate
/>
</>
)}
</div>
</div>
</div>
);
});
</Hotkeys>
);
},
);
export { ChatListItem as default };

View File

@ -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<IChatListShoutbox> = ({ onClick }) => {
const ChatListShoutbox: React.FC<IChatListShoutbox> = ({ onClick, onMoveUp, onMoveDown }) => {
const instance = useInstance();
const { logo } = useFrontendConfig();
const messages = useShoutboxMessages();
@ -26,53 +29,71 @@ const ChatListShoutbox: React.FC<IChatListShoutbox> = ({ 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 (
<div
<Hotkeys
handlers={handlers}
className='px-2'
tabIndex={0}
role='button'
key='shoutbox'
onClick={() => {
onClick('shoutbox');
}}
onKeyDown={handleKeyDown}
className='⁂-chat-list-item ⁂-chat-list-item--shoutbox'
data-testid='chat-list-item'
tabIndex={0}
>
<div>
<Avatar src={logo} alt='' size={40} className='flex-none' />
<div className='⁂-chat-list-item__content'>
<div className='⁂-chat-list-item__name'>
<p>
<FormattedMessage
id='chat_list_item_shoutbox'
defaultMessage='{instance} shoutbox'
values={{ instance: instance.title }}
/>
</p>
</div>
{lastMessage && (
<>
<p className='⁂-chat-list-item__message'>
{lastMessageAuthor && (
<span className='⁂-chat-list-item__message__author'>
<Emojify
text={lastMessageAuthor.display_name}
emojis={lastMessageAuthor.emojis}
/>
{': '}
</span>
)}
<ParsedContent html={lastMessage.text} />
<div className='⁂-chat-list-item ⁂-chat-list-item--shoutbox' data-testid='chat-list-item'>
<div>
<Avatar src={logo} alt='' size={40} className='flex-none' />
<div className='⁂-chat-list-item__content'>
<div className='⁂-chat-list-item__name'>
<p>
<FormattedMessage
id='chat_list_item_shoutbox'
defaultMessage='{instance} shoutbox'
values={{ instance: instance.title }}
/>
</p>
</>
)}
</div>
{lastMessage && (
<>
<p className='⁂-chat-list-item__message'>
{lastMessageAuthor && (
<span className='⁂-chat-list-item__message__author'>
<Emojify
text={lastMessageAuthor.display_name}
emojis={lastMessageAuthor.emojis}
/>
{': '}
</span>
)}
<ParsedContent html={lastMessage.text} />
</p>
</>
)}
</div>
</div>
</div>
</div>
</Hotkeys>
);
};

View File

@ -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<IChatList> = ({ onClickChat, useWindowScroll = false }) => {
const node = useRef<VirtuosoHandle | null>(null);
const showShoutbox = !useShoutboxIsLoading();
const {
@ -41,20 +44,47 @@ const ChatList: React.FC<IChatList> = ({ 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 (
<div key='shoutbox' className='px-2'>
<ChatListShoutbox onClick={onClickChat} />
<ChatListShoutbox
key='shoutbox'
onClick={onClickChat}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
/>
</div>
);
}
return (
<div key={chat.id} className='px-2'>
<ChatListItem chat={chat} onClick={onClickChat} />
</div>
<ChatListItem
key={chat.id}
chat={chat}
onClick={onClickChat}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
/>
);
},
[onClickChat],
@ -83,6 +113,7 @@ const ChatList: React.FC<IChatList> = ({ onClickChat, useWindowScroll = false })
})}
>
<Virtuoso
ref={node}
atTopStateChange={(atTop) => {
setNearTop(atTop);
}}