2026-09-20 01:39:04 +03:00
|
|
|
import { useCallback, useRef, useState } from 'react';
|
|
|
|
|
import { useMutation } from '@tanstack/react-query';
|
|
|
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
|
|
|
|
|
|
import { removeRelationship, type RelationshipUser } from '@/api/friends';
|
|
|
|
|
import { Avatar } from '@/components/ui/Avatar';
|
2026-09-20 01:52:27 +03:00
|
|
|
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
2026-09-20 01:39:04 +03:00
|
|
|
import { Menu, MenuItem, MenuSeparator } from '@/components/ui/Menu';
|
|
|
|
|
import { Popover } from '@/components/ui/Popover';
|
|
|
|
|
import { Button } from '@/components/ui/primitives';
|
|
|
|
|
import { DEFAULT_TIMEZONE } from '@/lib/timezones';
|
|
|
|
|
import { formatLastSeen, presenceDotClass } from '@/lib/presence';
|
|
|
|
|
import { useCurrentUser } from '@/lib/hooks';
|
|
|
|
|
import { useFriendsStore } from '@/stores/friends';
|
|
|
|
|
import { useOpenDirectChannel } from '@/components/friends/useOpenDirectChannel';
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Строка списка друзей: аватар с точкой статуса, имя (с подсказкой о
|
|
|
|
|
* последнем входе) и действия — «Написать» и меню «…».
|
|
|
|
|
*
|
|
|
|
|
* Подсказка появляется по наведению и по фокусу: имя — кнопка, поэтому
|
|
|
|
|
* доступна с клавиатуры, а клик по ней открывает беседу.
|
|
|
|
|
*/
|
|
|
|
|
export function FriendRow({ friend }: { friend: RelationshipUser }) {
|
|
|
|
|
const { t, i18n } = useTranslation();
|
|
|
|
|
const currentUser = useCurrentUser();
|
|
|
|
|
const reload = useFriendsStore((state) => state.reload);
|
|
|
|
|
const openDm = useOpenDirectChannel();
|
|
|
|
|
|
|
|
|
|
const [hintOpen, setHintOpen] = useState(false);
|
|
|
|
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
|
|
|
const nameRef = useRef<HTMLButtonElement | null>(null);
|
|
|
|
|
const menuRef = useRef<HTMLButtonElement | null>(null);
|
|
|
|
|
|
|
|
|
|
const remove = useMutation({
|
|
|
|
|
mutationFn: () => removeRelationship(friend.user_id),
|
|
|
|
|
onSuccess: () => reload(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const displayName = friend.display_name === '' ? friend.username : friend.display_name;
|
|
|
|
|
const status = friend.visible_status;
|
|
|
|
|
const statusLabel = t(`user.status.${status}`);
|
|
|
|
|
|
|
|
|
|
// Время последнего входа — в часовом поясе пользователя из настроек.
|
|
|
|
|
const time = formatLastSeen(
|
|
|
|
|
friend.last_seen_at,
|
|
|
|
|
currentUser.data?.timezone ?? DEFAULT_TIMEZONE,
|
|
|
|
|
i18n.language,
|
|
|
|
|
);
|
|
|
|
|
const hint =
|
|
|
|
|
status !== 'offline'
|
|
|
|
|
? t('friends.lastSeen.online')
|
|
|
|
|
: time === null
|
|
|
|
|
? t('friends.lastSeen.unknown')
|
|
|
|
|
: t('friends.lastSeen.at', { time });
|
|
|
|
|
|
|
|
|
|
const closeHint = useCallback(() => setHintOpen(false), []);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<li
|
|
|
|
|
className="relative flex items-center gap-2 rounded-[var(--radius-md)] px-2 py-1.5 transition-colors duration-[var(--duration-fast)] hover:bg-surface-2"
|
|
|
|
|
data-testid={`friend-row-${friend.user_id}`}
|
|
|
|
|
>
|
|
|
|
|
<button
|
|
|
|
|
ref={nameRef}
|
|
|
|
|
type="button"
|
|
|
|
|
aria-label={t('friends.open', { name: displayName })}
|
|
|
|
|
title={t('friends.open', { name: displayName })}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
openDm.mutate(friend.user_id);
|
|
|
|
|
}}
|
|
|
|
|
onMouseEnter={() => setHintOpen(true)}
|
|
|
|
|
onMouseLeave={closeHint}
|
|
|
|
|
onFocus={() => setHintOpen(true)}
|
|
|
|
|
onBlur={closeHint}
|
|
|
|
|
className="flex min-w-0 flex-1 items-center gap-3 rounded-[var(--radius-sm)] text-left"
|
|
|
|
|
data-testid={`friend-name-${friend.user_id}`}
|
|
|
|
|
>
|
|
|
|
|
<span className="relative shrink-0">
|
|
|
|
|
<Avatar
|
|
|
|
|
name={displayName}
|
|
|
|
|
seed={friend.user_id}
|
|
|
|
|
fileId={friend.avatar_file_id}
|
|
|
|
|
size="md"
|
|
|
|
|
/>
|
|
|
|
|
<span
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
data-testid={`friend-status-${friend.user_id}`}
|
|
|
|
|
data-status={status}
|
|
|
|
|
title={statusLabel}
|
|
|
|
|
className={`absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-surface-1 ${presenceDotClass[status]}`}
|
|
|
|
|
/>
|
|
|
|
|
</span>
|
|
|
|
|
<span className="min-w-0 flex-1">
|
|
|
|
|
<span className="block truncate text-sm font-medium">{displayName}</span>
|
|
|
|
|
<span className="block truncate text-xs text-fg-muted">@{friend.username}</span>
|
|
|
|
|
</span>
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<span className="flex shrink-0 items-center gap-1">
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
disabled={openDm.isPending}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
openDm.mutate(friend.user_id);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{t('friends.list.message')}
|
|
|
|
|
</Button>
|
|
|
|
|
<button
|
|
|
|
|
ref={menuRef}
|
|
|
|
|
type="button"
|
|
|
|
|
aria-haspopup="menu"
|
|
|
|
|
aria-expanded={menuOpen}
|
|
|
|
|
aria-label={t('friends.list.more', { name: displayName })}
|
|
|
|
|
title={t('friends.list.more', { name: displayName })}
|
|
|
|
|
className="rounded-[var(--radius-sm)] px-1.5 py-1 text-sm leading-none text-fg-muted transition-[color,background-color,transform] duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg active:scale-95"
|
|
|
|
|
onClick={() => setMenuOpen((value) => !value)}
|
|
|
|
|
>
|
|
|
|
|
<span aria-hidden="true">…</span>
|
|
|
|
|
</button>
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
|
|
<Popover
|
|
|
|
|
open={hintOpen}
|
|
|
|
|
label={t('friends.lastSeen.label')}
|
|
|
|
|
anchorRef={nameRef}
|
|
|
|
|
placement="bottom"
|
|
|
|
|
onClose={closeHint}
|
|
|
|
|
className="w-52"
|
|
|
|
|
>
|
|
|
|
|
<p className="px-1 py-0.5 text-xs text-fg">{hint}</p>
|
|
|
|
|
</Popover>
|
|
|
|
|
|
2026-09-20 01:52:27 +03:00
|
|
|
{openDm.isError || remove.isError ? (
|
|
|
|
|
<span className="absolute right-2 top-full z-10 w-64">
|
|
|
|
|
<ErrorNotice error={openDm.error ?? remove.error} />
|
|
|
|
|
</span>
|
|
|
|
|
) : null}
|
|
|
|
|
|
2026-09-20 01:39:04 +03:00
|
|
|
<Menu
|
|
|
|
|
open={menuOpen}
|
|
|
|
|
label={t('friends.list.menu', { name: displayName })}
|
|
|
|
|
anchorRef={menuRef}
|
|
|
|
|
placement="bottom"
|
|
|
|
|
align="right"
|
|
|
|
|
onClose={() => setMenuOpen(false)}
|
|
|
|
|
>
|
|
|
|
|
<MenuItem
|
|
|
|
|
icon="💬"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setMenuOpen(false);
|
|
|
|
|
openDm.mutate(friend.user_id);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{t('friends.list.message')}
|
|
|
|
|
</MenuItem>
|
|
|
|
|
<MenuSeparator />
|
|
|
|
|
<MenuItem
|
|
|
|
|
danger
|
|
|
|
|
icon="✖"
|
|
|
|
|
disabled={remove.isPending}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setMenuOpen(false);
|
|
|
|
|
remove.mutate();
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{t('friends.list.remove')}
|
|
|
|
|
</MenuItem>
|
|
|
|
|
<MenuItem
|
|
|
|
|
danger
|
|
|
|
|
icon="⛔"
|
|
|
|
|
disabled={remove.isPending}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setMenuOpen(false);
|
|
|
|
|
// Отдельной ручки блокировки нет: блокировка — тот же DELETE.
|
|
|
|
|
remove.mutate();
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{t('friends.list.block')}
|
|
|
|
|
</MenuItem>
|
|
|
|
|
</Menu>
|
|
|
|
|
</li>
|
|
|
|
|
);
|
|
|
|
|
}
|