Files
IdP/apps/frontend/components/family/family-sidebar-panel.tsx
2026-06-30 14:12:20 +03:00

216 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import Link from 'next/link';
import { useEffect, useMemo, useState } from 'react';
import { Loader2, Pin, Search, UsersRound } from 'lucide-react';
import { AvatarWithPresence } from '@/components/family/avatar-with-presence';
import { FamilyGroupSelector } from '@/components/family/family-group-selector';
import { FamilyInviteDialog } from '@/components/family/family-invite-dialog';
import { useFamilyOverlay } from '@/components/family/family-overlay-provider';
import { useAuth } from '@/components/id/auth-provider';
import { useSelectedFamily } from '@/hooks/use-primary-family';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { ChatRoom, fetchChatRooms, getApiErrorMessage, setChatRoomPinned } from '@/lib/api';
import { useToast } from '@/components/id/toast-provider';
export function FamilySidebarPanel() {
const { user, token, isContentReady } = useAuth();
const {
group,
groupSummaries,
loading,
hasFamily,
selectedGroupId,
setSelectedGroupId,
presenceByUserId,
refresh
} = useSelectedFamily(Boolean(user && token && isContentReady));
const { openChatWithMember } = useFamilyOverlay();
const { showToast } = useToast();
const [inviteOpen, setInviteOpen] = useState(false);
const [rooms, setRooms] = useState<ChatRoom[]>([]);
const [pinningRoomId, setPinningRoomId] = useState<string | null>(null);
const currentUserId = user?.id ?? '';
useEffect(() => {
if (!group?.id || !token) {
setRooms([]);
return;
}
fetchChatRooms(group.id, token)
.then((response) => setRooms(response.rooms ?? []))
.catch(() => setRooms([]));
}, [group?.id, token]);
const roomByPeerUserId = useMemo(() => {
const map = new Map<string, ChatRoom>();
for (const room of rooms) {
if (room.type !== 'DIRECT' && room.type !== 'BOT' && room.type !== 'E2E') continue;
const peer = room.members.find((member) => member.userId !== currentUserId);
if (peer) map.set(peer.userId, room);
}
return map;
}, [currentUserId, rooms]);
const sortedMembers = useMemo(() => {
return [...(group?.members ?? [])].sort((left, right) => {
const leftRoom = roomByPeerUserId.get(left.userId);
const rightRoom = roomByPeerUserId.get(right.userId);
const pinDiff = Number(Boolean(rightRoom?.isPinned)) - Number(Boolean(leftRoom?.isPinned));
if (pinDiff !== 0) return pinDiff;
if (leftRoom?.isPinned && rightRoom?.isPinned) {
return (rightRoom.pinnedAt ?? '').localeCompare(leftRoom.pinnedAt ?? '');
}
if (left.userId === currentUserId) return -1;
if (right.userId === currentUserId) return 1;
return left.displayName.localeCompare(right.displayName, 'ru');
});
}, [currentUserId, group?.members, roomByPeerUserId]);
async function toggleMemberPinned(memberUserId: string) {
const room = roomByPeerUserId.get(memberUserId);
if (!room || !token) {
showToast('Откройте чат с участником, чтобы его можно было закрепить');
return;
}
setPinningRoomId(room.id);
try {
const updated = await setChatRoomPinned(room.id, !room.isPinned, token);
setRooms((current) => current.map((item) => (item.id === updated.id ? updated : item)));
showToast(updated.isPinned ? 'Чат закреплён' : 'Чат откреплён');
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось закрепить чат') ?? 'Ошибка');
} finally {
setPinningRoomId(null);
}
}
if (!user || !token) return null;
return (
<>
<div className="mt-5 border-t border-[#eceef4] pt-4">
<div className="mb-2 flex items-center justify-between gap-1">
<div className="min-w-0">
<p className="truncate text-[11px] font-semibold uppercase tracking-wide text-[#667085]">Семья</p>
{hasFamily ? (
<FamilyGroupSelector
groups={groupSummaries}
selectedGroupId={selectedGroupId}
onSelect={setSelectedGroupId}
href={`/family/${group!.id}`}
nameClassName="text-xs font-medium text-[#1f2430]"
/>
) : (
<Link href="/family" className="text-xs font-medium text-[#3390ec] hover:underline">
Создать семью
</Link>
)}
</div>
{hasFamily ? (
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 rounded-lg"
aria-label="Найти и пригласить"
onClick={() => setInviteOpen(true)}
>
<Search className="h-3.5 w-3.5" />
</Button>
) : null}
</div>
{loading ? (
<div className="flex items-center gap-2 py-2 text-[11px] text-[#667085]">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Загрузка...
</div>
) : hasFamily ? (
<div className="space-y-1 overflow-y-auto pr-1">
{sortedMembers.map((member) => {
const presence = presenceByUserId.get(member.userId);
const isSelf = member.userId === currentUserId;
const memberRoom = roomByPeerUserId.get(member.userId);
const memberPinned = Boolean(memberRoom?.isPinned);
return (
<div
key={member.id}
className={cn(
'flex w-full items-center gap-1 rounded-xl transition hover:bg-[#f4f5f8]',
isSelf && 'bg-[#fafbfd]'
)}
>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-left"
onClick={() => openChatWithMember(member.userId, member.displayName, {
isBot: member.isBot,
botUsername: member.botUsername
})}
title={isSelf ? 'Вы' : `Написать ${member.displayName}`}
>
<AvatarWithPresence
userId={member.userId}
displayName={member.displayName}
hasAvatar={member.hasAvatar}
token={token}
isVerified={member.isVerified}
verificationIcon={member.verificationIcon}
online={isSelf ? undefined : presence?.online}
className="h-8 w-8"
/>
<div className="min-w-0 flex-1">
<p className="flex min-w-0 items-center gap-1 truncate text-[12px] font-medium leading-tight">
<span className="truncate">{member.displayName}</span>
{member.isBot ? <span>🤖</span> : null}
{isSelf ? <span>(Вы)</span> : null}
{memberPinned ? <Pin className="h-3 w-3 shrink-0 fill-[#8a8f9e] text-[#8a8f9e]" /> : null}
</p>
{!isSelf ? (
<p className={cn('truncate text-[10px]', member.isBot ? 'text-[#3390ec]' : presence?.online ? 'text-emerald-600' : 'text-[#a8adbc]')}>
{member.isBot ? 'Бот' : presence?.online ? 'В сети' : 'Не в сети'}
</p>
) : null}
</div>
</button>
{!isSelf ? (
<button
type="button"
aria-label={memberPinned ? 'Открепить чат' : 'Закрепить чат'}
className="mr-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-[#8a8f9e] transition hover:bg-[#eceef4] hover:text-[#667085]"
onClick={() => void toggleMemberPinned(member.userId)}
>
{pinningRoomId === memberRoom?.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Pin className={cn('h-3.5 w-3.5', memberPinned && 'fill-[#8a8f9e]')} />
)}
</button>
) : null}
</div>
);
})}
</div>
) : (
<div className="rounded-xl bg-[#f4f5f8] px-2 py-3 text-[11px] text-[#667085]">
<UsersRound className="mb-1 h-4 w-4" />
Приглашайте близких и общайтесь в чатах
</div>
)}
</div>
{hasFamily ? (
<FamilyInviteDialog
groupId={group!.id}
group={group}
open={inviteOpen}
onOpenChange={setInviteOpen}
onInvited={() => void refresh()}
/>
) : null}
</>
);
}