Files
glchat/web/src/components/friends/FriendRow.tsx
T
grendervill a36ee9527b feat(cosmetics): оформление «для друзей» в друзьях и личных беседах
- API: список друзей отдаёт вычисленное оформление «для друзей» одним запросом
  на весь список (AGENT.md 7.2)
- клиент: рамка, иконка, цвет и эффект ника в списке друзей, в ленте личных
  бесед — из профиля собеседника
- тесты: Go на список друзей, Vitest на отрисовку в друзьях
2026-09-21 01:48:56 +03:00

192 lines
6.8 KiB
TypeScript

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 { FramedAvatar, Nickname } from '@/components/profile/Cosmetics';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
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">
<FramedAvatar
name={displayName}
seed={friend.user_id}
fileId={friend.avatar_file_id}
size="md"
cosmetics={friend.cosmetics}
/>
<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">
{/* Ник с оформлением «для друзей»: цвет, эффект и иконка. */}
<Nickname
name={displayName}
cosmetics={friend.cosmetics}
className="text-sm font-medium"
/>
<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>
{openDm.isError || remove.isError ? (
<span className="absolute right-2 top-full z-10 w-64">
<ErrorNotice error={openDm.error ?? remove.error} />
</span>
) : null}
<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>
);
}