nicolium: make chat list hotkey-navigable
Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
@ -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';
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -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);
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user