fix(files): аватары и баннеры доступны авторизованным пользователям
Аватар нельзя было показать в списке друзей: файл без комнаты был доступен только загрузившему. Добавлено назначение файла (`files.purpose`: attachment | avatar | banner, миграция 00006): аватары и баннеры отдаются любому авторизованному пользователю, вложения по-прежнему проверяются по правам комнаты, а непривязанные загрузки остаются приватными.
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
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';
|
||||
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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user