fix and update

This commit is contained in:
lendry
2026-06-25 16:01:41 +03:00
parent ce58e6f4c1
commit 3880c68d59
40 changed files with 1715 additions and 215 deletions

View File

@@ -0,0 +1,55 @@
'use client';
import { UserAvatar } from '@/components/id/user-avatar';
import { cn } from '@/lib/utils';
export function OnlineDot({ online, className }: { online?: boolean; className?: string }) {
if (!online) return null;
return (
<span
className={cn(
'absolute bottom-0 right-0 z-10 h-3 w-3 rounded-full border-2 border-white bg-emerald-500 shadow-sm',
className
)}
aria-hidden
/>
);
}
export function AvatarWithPresence({
userId,
displayName,
hasAvatar,
token,
isVerified,
verificationIcon,
online,
className = 'h-9 w-9',
badgeSize = 'xs'
}: {
userId: string;
displayName?: string;
hasAvatar?: boolean;
token: string | null;
isVerified?: boolean;
verificationIcon?: string | null;
online?: boolean;
className?: string;
badgeSize?: 'xs' | 'sm' | 'md' | 'lg';
}) {
return (
<div className="relative inline-flex shrink-0">
<UserAvatar
userId={userId}
displayName={displayName}
hasAvatar={hasAvatar}
token={token}
isVerified={isVerified}
verificationIcon={verificationIcon}
className={className}
badgeSize={badgeSize}
/>
<OnlineDot online={online} />
</div>
);
}

View File

@@ -0,0 +1,76 @@
'use client';
import Link from 'next/link';
import { ChevronDown } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
type FamilyGroupOption = {
id: string;
name: string;
};
export function FamilyGroupSelector({
groups,
selectedGroupId,
onSelect,
href,
className,
nameClassName
}: {
groups: FamilyGroupOption[];
selectedGroupId: string | null;
onSelect: (groupId: string) => void;
href?: string;
className?: string;
nameClassName?: string;
}) {
const selected = groups.find((group) => group.id === selectedGroupId) ?? groups[0];
if (!selected) return null;
const nameElement = href ? (
<Link href={href} className={cn('truncate hover:underline', nameClassName)} onClick={(event) => event.stopPropagation()}>
{selected.name}
</Link>
) : (
<span className={cn('truncate', nameClassName)}>{selected.name}</span>
);
if (groups.length <= 1) {
return <div className={cn('min-w-0', className)}>{nameElement}</div>;
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
'flex min-w-0 max-w-full items-center gap-0.5 rounded-lg text-left transition hover:bg-[#f4f5f8]',
className
)}
>
{nameElement}
<ChevronDown className="h-3 w-3 shrink-0 text-[#667085]" aria-hidden />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-w-[220px] rounded-xl">
{groups.map((group) => (
<DropdownMenuItem
key={group.id}
className={cn('truncate rounded-lg', group.id === selected.id && 'font-medium text-[#3390ec]')}
onClick={() => onSelect(group.id)}
>
{group.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -23,7 +23,10 @@ import {
Volume2,
VolumeX
} from 'lucide-react';
import { AvatarWithPresence } from '@/components/family/avatar-with-presence';
import { FamilyInviteDialog } from '@/components/family/family-invite-dialog';
import { VoiceMessagePlayer } from '@/components/chat/voice-message-player';
import { UserAvatar } from '@/components/id/user-avatar';
import { useAuth } from '@/components/id/auth-provider';
import { useToast } from '@/components/id/toast-provider';
import { useRealtime } from '@/components/notifications/realtime-provider';
@@ -48,7 +51,6 @@ import {
deleteFamilyGroup,
editChatMessage,
FamilyGroup,
FamilyInviteCandidate,
FamilyPresenceMember,
fetchChatMessages,
fetchChatRooms,
@@ -58,9 +60,7 @@ import {
markChatRoomRead,
removeChatRoomMember,
removeFamilyMember,
searchFamilyInviteUsers,
sendChatMessage,
sendFamilyInvite,
setChatRoomMuted,
updateFamilyGroup,
uploadMediaObject,
@@ -132,18 +132,6 @@ function messagePreview(message?: ChatMessage) {
return message.content ?? '';
}
function OnlineDot({ online, className }: { online?: boolean; className?: string }) {
if (!online) return null;
return (
<span
className={cn(
'absolute bottom-0 right-0 h-3 w-3 rounded-full border-2 border-white bg-emerald-500 shadow-sm',
className
)}
/>
);
}
function FamilyAvatar({ groupId, name, hasAvatar, token }: { groupId: string; name: string; hasAvatar?: boolean; token: string | null }) {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
@@ -206,10 +194,6 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
const [membersOpen, setMembersOpen] = useState(false);
const [familyMembersOpen, setFamilyMembersOpen] = useState(false);
const [addMembersOpen, setAddMembersOpen] = useState(false);
const [inviteQuery, setInviteQuery] = useState('');
const [inviteResults, setInviteResults] = useState<FamilyInviteCandidate[]>([]);
const [selectedInviteUser, setSelectedInviteUser] = useState<FamilyInviteCandidate | null>(null);
const [inviteSearching, setInviteSearching] = useState(false);
const [renameValue, setRenameValue] = useState('');
const [newRoomName, setNewRoomName] = useState('');
const [selectedMembers, setSelectedMembers] = useState<string[]>([]);
@@ -579,39 +563,6 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
}
}
async function submitInvite() {
if (!token || !selectedInviteUser) return;
try {
await sendFamilyInvite(groupId, { inviteeUserId: selectedInviteUser.id }, token);
setInviteOpen(false);
setInviteQuery('');
setInviteResults([]);
setSelectedInviteUser(null);
showToast('Приглашение отправлено');
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось отправить приглашение') ?? 'Ошибка');
}
}
useEffect(() => {
if (!inviteOpen || !token || selectedInviteUser) {
return;
}
const query = inviteQuery.trim();
if (query.length < 2) {
setInviteResults([]);
return;
}
const timer = window.setTimeout(() => {
setInviteSearching(true);
void searchFamilyInviteUsers(groupId, query, token)
.then((response) => setInviteResults(response.users ?? []))
.catch(() => setInviteResults([]))
.finally(() => setInviteSearching(false));
}, 300);
return () => window.clearTimeout(timer);
}, [groupId, inviteOpen, inviteQuery, selectedInviteUser, token]);
async function submitCreateRoom() {
if (!token || !newRoomName.trim()) return;
try {
@@ -941,7 +892,19 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
const isEditing = editingMessageId === message.id;
const canEdit = mine && !message.isDeleted && (message.type === 'TEXT' || message.type === 'EMOJI');
return (
<div key={message.id} className={cn('group flex', mine ? 'justify-end' : 'justify-start')}>
<div key={message.id} className={cn('group flex gap-2', mine ? 'justify-end' : 'justify-start')}>
{!mine ? (
<UserAvatar
userId={message.senderId}
displayName={message.senderName}
hasAvatar={message.senderHasAvatar}
token={token}
isVerified={message.senderIsVerified}
verificationIcon={message.senderVerificationIcon}
className="h-8 w-8 self-end"
badgeSize="xs"
/>
) : null}
<div
className={cn(
'relative max-w-[78%] rounded-[18px] px-3 py-2 shadow-sm',
@@ -1176,74 +1139,12 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
event.target.value = '';
}} />
<Dialog open={inviteOpen} onOpenChange={(open) => {
setInviteOpen(open);
if (!open) {
setInviteQuery('');
setInviteResults([]);
setSelectedInviteUser(null);
}
}}>
<DialogContent className="rounded-[28px] sm:max-w-[420px]">
<DialogHeader><DialogTitle>Пригласить в семью</DialogTitle></DialogHeader>
{selectedInviteUser ? (
<div className="rounded-2xl bg-[#f4f5f8] px-4 py-3">
<p className="font-medium">{selectedInviteUser.displayName}</p>
<p className="text-sm text-[#667085]">
{selectedInviteUser.email ?? selectedInviteUser.phone ?? selectedInviteUser.username ?? 'Контакт не указан'}
</p>
<button type="button" className="mt-2 text-sm text-[#667085] underline" onClick={() => setSelectedInviteUser(null)}>
Выбрать другого пользователя
</button>
</div>
) : (
<>
<Input
placeholder="ФИО, почта, телефон или логин"
value={inviteQuery}
onChange={(event) => setInviteQuery(event.target.value)}
/>
<div className="mt-2 max-h-56 overflow-y-auto rounded-2xl border border-[#eceef4]">
{inviteSearching ? (
<div className="flex items-center gap-2 px-4 py-3 text-sm text-[#667085]">
<Loader2 className="h-4 w-4 animate-spin" />
Ищем пользователей...
</div>
) : inviteQuery.trim().length < 2 ? (
<p className="px-4 py-3 text-sm text-[#667085]">Введите минимум 2 символа для поиска</p>
) : inviteResults.length ? (
inviteResults.map((candidate) => (
<button
key={candidate.id}
type="button"
className="flex w-full items-center gap-3 border-b border-[#eceef4] px-4 py-3 text-left last:border-b-0 hover:bg-[#fafbfd]"
onClick={() => {
setSelectedInviteUser(candidate);
setInviteResults([]);
}}
>
<Avatar className="h-9 w-9">
<AvatarFallback>{candidate.displayName.slice(0, 1)}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="truncate font-medium">{candidate.displayName}</p>
<p className="truncate text-sm text-[#667085]">
{candidate.email ?? candidate.phone ?? candidate.username ?? 'Без контактов'}
</p>
</div>
</button>
))
) : (
<p className="px-4 py-3 text-sm text-[#667085]">Пользователи не найдены</p>
)}
</div>
</>
)}
<Button className="mt-4 w-full rounded-xl" disabled={!selectedInviteUser} onClick={() => void submitInvite()}>
Отправить приглашение
</Button>
</DialogContent>
</Dialog>
<FamilyInviteDialog
groupId={groupId}
open={inviteOpen}
onOpenChange={setInviteOpen}
onInvited={() => void loadGroup()}
/>
<Dialog open={familyMembersOpen} onOpenChange={setFamilyMembersOpen}>
<DialogContent className="rounded-[28px] sm:max-w-[480px]">
@@ -1262,21 +1163,27 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
return (
<div key={member.id} className="flex items-center gap-3 rounded-2xl bg-[#f4f5f8] px-3 py-3">
<div className="relative shrink-0">
<Avatar className="h-10 w-10">
<AvatarFallback>{member.displayName.slice(0, 1)}</AvatarFallback>
</Avatar>
<OnlineDot online={presence?.online} />
</div>
<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-10 w-10"
/>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">
{member.displayName}
{isSelf ? <span className="ml-1 text-xs font-normal text-[#667085]">(Вы)</span> : null}
</p>
<p className="text-xs text-[#667085]">{familyMemberRoleLabel(member.role)}</p>
<p className={cn('text-xs', presence?.online ? 'font-medium text-emerald-600' : 'text-[#a8adbc]')}>
{presence ? formatPresenceStatus(presence) : 'Статус неизвестен'}
</p>
{!isSelf ? (
<p className={cn('text-xs', presence?.online ? 'font-medium text-emerald-600' : 'text-[#a8adbc]')}>
{presence ? formatPresenceStatus(presence) : 'Статус неизвестен'}
</p>
) : null}
</div>
{canRemove ? (
<Button
@@ -1357,12 +1264,16 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
return (
<div key={member.id} className="flex items-center gap-3 rounded-2xl bg-[#f4f5f8] px-3 py-3">
<div className="relative shrink-0">
<Avatar className="h-10 w-10">
<AvatarFallback>{member.displayName.slice(0, 1)}</AvatarFallback>
</Avatar>
<OnlineDot online={presence?.online} />
</div>
<AvatarWithPresence
userId={member.userId}
displayName={member.displayName}
hasAvatar={member.hasAvatar}
token={token}
isVerified={member.isVerified}
verificationIcon={member.verificationIcon}
online={presence?.online}
className="h-10 w-10"
/>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{member.displayName}</p>
<p className="text-xs text-[#667085]">{memberRoleLabel(member)}</p>
@@ -1404,22 +1315,32 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
<DialogHeader><DialogTitle>Добавить в чат</DialogTitle></DialogHeader>
<div className="space-y-2">
{familyMembersNotInRoom.length ? (
familyMembersNotInRoom.map((member) => (
familyMembersNotInRoom.map((member) => {
const presence = presenceByUserId.get(member.userId);
return (
<button
key={member.id}
type="button"
className="flex w-full items-center gap-3 rounded-2xl bg-[#f4f5f8] px-3 py-3 text-left transition hover:bg-[#eceef4]"
onClick={() => void handleAddRoomMember(member.userId)}
>
<Avatar className="h-9 w-9">
<AvatarFallback>{member.displayName.slice(0, 1)}</AvatarFallback>
</Avatar>
<AvatarWithPresence
userId={member.userId}
displayName={member.displayName}
hasAvatar={member.hasAvatar}
token={token}
isVerified={member.isVerified}
verificationIcon={member.verificationIcon}
online={presence?.online}
className="h-9 w-9"
/>
<div>
<p className="font-medium">{member.displayName}</p>
<p className="text-xs text-[#667085]">{member.role === 'owner' ? 'Создатель семьи' : 'Участник семьи'}</p>
</div>
</button>
))
);
})
) : (
<p className="py-6 text-center text-sm text-[#667085]">Все участники семьи уже в этом чате</p>
)}

View File

@@ -0,0 +1,141 @@
'use client';
import { useEffect, useState } from 'react';
import { Loader2 } from 'lucide-react';
import { AvatarWithPresence } from '@/components/family/avatar-with-presence';
import { useAuth } from '@/components/id/auth-provider';
import { useToast } from '@/components/id/toast-provider';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { FamilyInviteCandidate, getApiErrorMessage, searchFamilyInviteUsers, sendFamilyInvite } from '@/lib/api';
export function FamilyInviteDialog({
groupId,
open,
onOpenChange,
onInvited
}: {
groupId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
onInvited?: () => void;
}) {
const { token } = useAuth();
const { showToast } = useToast();
const [inviteQuery, setInviteQuery] = useState('');
const [inviteResults, setInviteResults] = useState<FamilyInviteCandidate[]>([]);
const [inviteSearching, setInviteSearching] = useState(false);
const [selectedInviteUser, setSelectedInviteUser] = useState<FamilyInviteCandidate | null>(null);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!open) {
setInviteQuery('');
setInviteResults([]);
setSelectedInviteUser(null);
return;
}
if (!token || inviteQuery.trim().length < 2) {
setInviteResults([]);
return;
}
const timer = window.setTimeout(() => {
setInviteSearching(true);
searchFamilyInviteUsers(groupId, inviteQuery.trim(), token)
.then((response) => setInviteResults(response.users ?? []))
.catch(() => setInviteResults([]))
.finally(() => setInviteSearching(false));
}, 300);
return () => window.clearTimeout(timer);
}, [groupId, inviteQuery, open, token]);
async function submitInvite() {
if (!token || !selectedInviteUser) return;
setSubmitting(true);
try {
await sendFamilyInvite(groupId, { inviteeUserId: selectedInviteUser.id }, token);
showToast('Приглашение отправлено');
onOpenChange(false);
onInvited?.();
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось отправить приглашение') ?? 'Ошибка');
} finally {
setSubmitting(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="rounded-[28px] sm:max-w-[420px]">
<DialogHeader>
<DialogTitle>Пригласить в семью</DialogTitle>
</DialogHeader>
{selectedInviteUser ? (
<div className="rounded-2xl bg-[#f4f5f8] px-4 py-3">
<p className="font-medium">{selectedInviteUser.displayName}</p>
<p className="text-sm text-[#667085]">
{selectedInviteUser.email ?? selectedInviteUser.phone ?? selectedInviteUser.username ?? 'Контакт не указан'}
</p>
<button type="button" className="mt-2 text-sm text-[#667085] underline" onClick={() => setSelectedInviteUser(null)}>
Выбрать другого пользователя
</button>
</div>
) : (
<>
<Input
placeholder="ФИО, почта, телефон или логин"
value={inviteQuery}
onChange={(event) => setInviteQuery(event.target.value)}
/>
<div className="mt-2 max-h-56 overflow-y-auto rounded-2xl border border-[#eceef4]">
{inviteSearching ? (
<div className="flex items-center gap-2 px-4 py-3 text-sm text-[#667085]">
<Loader2 className="h-4 w-4 animate-spin" />
Ищем пользователей...
</div>
) : inviteQuery.trim().length < 2 ? (
<p className="px-4 py-3 text-sm text-[#667085]">Введите минимум 2 символа для поиска</p>
) : inviteResults.length ? (
inviteResults.map((candidate) => (
<button
key={candidate.id}
type="button"
className="flex w-full items-center gap-3 border-b border-[#eceef4] px-4 py-3 text-left last:border-b-0 hover:bg-[#fafbfd]"
onClick={() => {
setSelectedInviteUser(candidate);
setInviteResults([]);
}}
>
<AvatarWithPresence
userId={candidate.id}
displayName={candidate.displayName}
hasAvatar={candidate.hasAvatar}
token={token}
isVerified={candidate.isVerified}
verificationIcon={candidate.verificationIcon}
className="h-9 w-9"
/>
<div className="min-w-0">
<p className="truncate font-medium">{candidate.displayName}</p>
<p className="truncate text-sm text-[#667085]">
{candidate.email ?? candidate.phone ?? candidate.username ?? 'Без контактов'}
</p>
</div>
</button>
))
) : (
<p className="px-4 py-3 text-sm text-[#667085]">Пользователи не найдены</p>
)}
</div>
</>
)}
<Button className="mt-4 w-full rounded-xl" disabled={!selectedInviteUser || submitting} onClick={() => void submitInvite()}>
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Отправить приглашение'}
</Button>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,111 @@
'use client';
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
const SELECTED_FAMILY_STORAGE_KEY = 'id-selected-family-group-id';
type FamilyOverlayContextValue = {
openMiniChat: () => void;
openChatRoom: (roomId: string) => void;
openChatWithMember: (memberUserId: string, memberName: string) => void;
closeMiniChat: () => void;
miniChatOpen: boolean;
pendingRoomId: string | null;
pendingMember: { userId: string; name: string } | null;
clearPending: () => void;
selectedGroupId: string | null;
setSelectedGroupId: (groupId: string | null) => void;
};
const FamilyOverlayContext = createContext<FamilyOverlayContextValue | null>(null);
export function FamilyOverlayProvider({ children }: { children: React.ReactNode }) {
const [miniChatOpen, setMiniChatOpen] = useState(false);
const [pendingRoomId, setPendingRoomId] = useState<string | null>(null);
const [pendingMember, setPendingMember] = useState<{ userId: string; name: string } | null>(null);
const [selectedGroupId, setSelectedGroupIdState] = useState<string | null>(null);
const pendingMemberRef = useRef<{ userId: string; name: string } | null>(null);
const selectionHydratedRef = useRef(false);
useEffect(() => {
if (selectionHydratedRef.current) return;
selectionHydratedRef.current = true;
const stored = window.localStorage.getItem(SELECTED_FAMILY_STORAGE_KEY);
if (stored) setSelectedGroupIdState(stored);
}, []);
const setSelectedGroupId = useCallback((groupId: string | null) => {
setSelectedGroupIdState(groupId);
if (groupId) {
window.localStorage.setItem(SELECTED_FAMILY_STORAGE_KEY, groupId);
} else {
window.localStorage.removeItem(SELECTED_FAMILY_STORAGE_KEY);
}
}, []);
const openMiniChat = useCallback(() => {
setMiniChatOpen(true);
}, []);
const closeMiniChat = useCallback(() => {
setMiniChatOpen(false);
}, []);
const openChatRoom = useCallback((roomId: string) => {
setPendingRoomId(roomId);
setPendingMember(null);
pendingMemberRef.current = null;
setMiniChatOpen(true);
}, []);
const openChatWithMember = useCallback((memberUserId: string, memberName: string) => {
const payload = { userId: memberUserId, name: memberName };
setPendingMember(payload);
pendingMemberRef.current = payload;
setPendingRoomId(null);
setMiniChatOpen(true);
}, []);
const clearPending = useCallback(() => {
setPendingRoomId(null);
setPendingMember(null);
pendingMemberRef.current = null;
}, []);
const value = useMemo(
() => ({
openMiniChat,
openChatRoom,
openChatWithMember,
closeMiniChat,
miniChatOpen,
pendingRoomId,
pendingMember,
clearPending,
selectedGroupId,
setSelectedGroupId
}),
[
clearPending,
closeMiniChat,
miniChatOpen,
openChatRoom,
openChatWithMember,
openMiniChat,
pendingMember,
pendingRoomId,
selectedGroupId,
setSelectedGroupId
]
);
return <FamilyOverlayContext.Provider value={value}>{children}</FamilyOverlayContext.Provider>;
}
export function useFamilyOverlay() {
const context = useContext(FamilyOverlayContext);
if (!context) {
throw new Error('useFamilyOverlay must be used within FamilyOverlayProvider');
}
return context;
}

View File

@@ -0,0 +1,130 @@
'use client';
import Link from 'next/link';
import { useState } from 'react';
import { Loader2, 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';
export function FamilySidebarPanel() {
const { user, token } = useAuth();
const {
group,
groupSummaries,
loading,
hasFamily,
selectedGroupId,
setSelectedGroupId,
presenceByUserId,
refresh
} = useSelectedFamily(Boolean(user && token));
const { openChatWithMember } = useFamilyOverlay();
const [inviteOpen, setInviteOpen] = useState(false);
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="max-h-[220px] space-y-1 overflow-y-auto pr-1">
{(group?.members ?? []).map((member) => {
const presence = presenceByUserId.get(member.userId);
const isSelf = member.userId === user.id;
return (
<button
key={member.id}
type="button"
className={cn(
'flex w-full items-center gap-2 rounded-xl px-2 py-1.5 text-left transition hover:bg-[#f4f5f8]',
isSelf && 'bg-[#fafbfd]'
)}
onClick={() => openChatWithMember(member.userId, member.displayName)}
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="truncate text-[12px] font-medium leading-tight">
{member.displayName}
{isSelf ? ' (Вы)' : ''}
</p>
{!isSelf ? (
<p className={cn('truncate text-[10px]', presence?.online ? 'text-emerald-600' : 'text-[#a8adbc]')}>
{presence?.online ? 'В сети' : 'Не в сети'}
</p>
) : null}
</div>
</button>
);
})}
</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}
open={inviteOpen}
onOpenChange={setInviteOpen}
onInvited={() => void refresh()}
/>
) : null}
</>
);
}

View File

@@ -0,0 +1,394 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Link from 'next/link';
import { ArrowLeft, Loader2, MessageCircle, Send, X } from 'lucide-react';
import { AvatarWithPresence } from '@/components/family/avatar-with-presence';
import { useFamilyOverlay } from '@/components/family/family-overlay-provider';
import { useAuth } from '@/components/id/auth-provider';
import { useToast } from '@/components/id/toast-provider';
import { useRealtime } from '@/components/notifications/realtime-provider';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useSelectedFamily } from '@/hooks/use-primary-family';
import { FamilyGroupSelector } from '@/components/family/family-group-selector';
import {
ChatMessage,
ChatRoom,
fetchChatMessages,
fetchChatRooms,
getApiErrorMessage,
markChatRoomRead,
sendChatMessage
} from '@/lib/api';
import { findOrCreateMemberChat, roomDisplayLabel } from '@/lib/family-chat';
import { cn } from '@/lib/utils';
function formatMessageTime(value: string) {
return new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }).format(new Date(value));
}
export function MiniFamilyChat() {
const { user, token, isPinLocked } = useAuth();
const { showToast } = useToast();
const { subscribe } = useRealtime();
const {
group,
groupSummaries,
hasFamily,
selectedGroupId,
setSelectedGroupId,
presenceByUserId
} = useSelectedFamily(Boolean(user && token && !isPinLocked));
const {
miniChatOpen,
openMiniChat,
closeMiniChat,
pendingRoomId,
pendingMember,
clearPending
} = useFamilyOverlay();
const [view, setView] = useState<'picker' | 'chat'>('picker');
const [rooms, setRooms] = useState<ChatRoom[]>([]);
const [activeRoom, setActiveRoom] = useState<ChatRoom | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [draft, setDraft] = useState('');
const [loadingRooms, setLoadingRooms] = useState(false);
const [loadingMessages, setLoadingMessages] = useState(false);
const [sending, setSending] = useState(false);
const [openingMemberId, setOpeningMemberId] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const previousGroupIdRef = useRef<string | null>(null);
const loadRooms = useCallback(async () => {
if (!group || !token) return;
setLoadingRooms(true);
try {
const response = await fetchChatRooms(group.id, token);
setRooms(response.rooms ?? []);
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось загрузить чаты') ?? 'Ошибка');
} finally {
setLoadingRooms(false);
}
}, [group, showToast, token]);
const openRoom = useCallback(
async (room: ChatRoom) => {
setActiveRoom(room);
setView('chat');
setLoadingMessages(true);
try {
const response = await fetchChatMessages(room.id, token);
setMessages(response.messages ?? []);
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось загрузить сообщения') ?? 'Ошибка');
} finally {
setLoadingMessages(false);
}
},
[showToast, token]
);
const openMemberChat = useCallback(
async (memberUserId: string, memberName: string) => {
if (!group || !token || !user) return;
setOpeningMemberId(memberUserId);
try {
const room = await findOrCreateMemberChat(group.id, memberUserId, memberName, user.id, token);
await loadRooms();
await openRoom(room);
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось открыть чат') ?? 'Ошибка');
} finally {
setOpeningMemberId(null);
}
},
[group, loadRooms, openRoom, showToast, token, user]
);
useEffect(() => {
if (!miniChatOpen || !hasFamily) return;
void loadRooms();
}, [group?.id, hasFamily, loadRooms, miniChatOpen]);
useEffect(() => {
if (!group?.id) return;
if (previousGroupIdRef.current && previousGroupIdRef.current !== group.id && miniChatOpen) {
setView('picker');
setActiveRoom(null);
setMessages([]);
setDraft('');
}
previousGroupIdRef.current = group.id;
}, [group?.id, miniChatOpen]);
useEffect(() => {
if (!miniChatOpen) {
setView('picker');
setActiveRoom(null);
setMessages([]);
setDraft('');
clearPending();
return;
}
if (pendingRoomId && rooms.length) {
const room = rooms.find((item) => item.id === pendingRoomId);
if (room) {
void openRoom(room);
clearPending();
}
} else if (pendingMember && user) {
void openMemberChat(pendingMember.userId, pendingMember.name);
clearPending();
}
}, [clearPending, miniChatOpen, openMemberChat, openRoom, pendingMember, pendingRoomId, rooms, user]);
useEffect(() => {
if (!activeRoom) return;
return subscribe((event) => {
const payload = event.payload;
if (payload?.roomId && payload.roomId !== activeRoom.id) return;
if (event.type === 'chat_message' && payload?.message) {
const message = payload.message as ChatMessage;
setMessages((current) => {
if (current.some((item) => item.id === message.id)) return current;
return [...current, message];
});
}
if (event.type === 'chat_message_updated' || event.type === 'chat_message_deleted') {
const updated = payload?.message;
if (updated) {
setMessages((current) => current.map((item) => (item.id === updated.id ? updated : item)));
}
}
});
}, [activeRoom, subscribe]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
useEffect(() => {
if (!activeRoom || !token || !messages.length) return;
const last = messages[messages.length - 1];
const timer = window.setTimeout(() => {
void markChatRoomRead(activeRoom.id, last.id, token).catch(() => {});
}, 400);
return () => window.clearTimeout(timer);
}, [activeRoom, messages, token]);
async function handleSend() {
if (!token || !activeRoom || !draft.trim()) return;
setSending(true);
try {
const message = await sendChatMessage(activeRoom.id, { type: 'TEXT', content: draft.trim() }, token);
setMessages((current) => [...current, message]);
setDraft('');
void loadRooms();
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось отправить сообщение') ?? 'Ошибка');
} finally {
setSending(false);
}
}
const otherMembers = useMemo(
() => (group?.members ?? []).filter((member) => member.userId !== user?.id),
[group?.members, user?.id]
);
if (!user || !token || isPinLocked) return null;
return (
<>
<button
type="button"
onClick={() => (miniChatOpen ? closeMiniChat() : openMiniChat())}
className={cn(
'fixed bottom-5 right-5 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-[#3390ec] text-white shadow-lg transition hover:bg-[#2b7fd4] lg:bottom-6 lg:right-6',
miniChatOpen && 'scale-95'
)}
aria-label={miniChatOpen ? 'Закрыть чат семьи' : 'Открыть чат семьи'}
>
{miniChatOpen ? <X className="h-5 w-5" /> : <MessageCircle className="h-5 w-5" />}
</button>
{miniChatOpen ? (
<div className="fixed bottom-20 right-5 z-50 flex w-[min(92vw,380px)] flex-col overflow-hidden rounded-[24px] border border-[#eceef4] bg-white shadow-2xl lg:bottom-[5.5rem] lg:right-6">
<div className="flex items-center gap-2 border-b border-[#eceef4] px-4 py-3">
{view === 'chat' ? (
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={() => setView('picker')}>
<ArrowLeft className="h-4 w-4" />
</Button>
) : null}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">
{view === 'chat' && activeRoom && user ? roomDisplayLabel(activeRoom, user.id) : 'Чат семьи'}
</p>
{hasFamily ? (
<FamilyGroupSelector
groups={groupSummaries}
selectedGroupId={selectedGroupId}
onSelect={setSelectedGroupId}
href={`/family/${group!.id}`}
nameClassName="text-xs text-[#667085]"
/>
) : (
<p className="text-xs text-[#667085]">Семья не создана</p>
)}
</div>
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={closeMiniChat}>
<X className="h-4 w-4" />
</Button>
</div>
{!hasFamily ? (
<div className="space-y-3 p-5 text-sm text-[#667085]">
<p>Создайте семейную группу, чтобы общаться с близкими из любого раздела.</p>
<Link href="/family" className="inline-flex text-[#3390ec] hover:underline" onClick={closeMiniChat}>
Перейти к созданию семьи
</Link>
</div>
) : view === 'picker' ? (
<div className="max-h-[min(60vh,460px)] overflow-y-auto p-3">
<section className="mb-4">
<p className="mb-2 px-1 text-xs font-semibold uppercase tracking-wide text-[#667085]">Чаты</p>
{loadingRooms ? (
<div className="flex items-center gap-2 px-2 py-3 text-sm text-[#667085]">
<Loader2 className="h-4 w-4 animate-spin" />
Загрузка...
</div>
) : rooms.length ? (
<div className="space-y-1">
{rooms.map((room) => (
<button
key={room.id}
type="button"
className="flex w-full items-center gap-3 rounded-xl px-2 py-2 text-left transition hover:bg-[#f4f5f8]"
onClick={() => void openRoom(room)}
>
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-[#eef4ff] text-xs font-semibold text-[#3390ec]">
{room.type === 'GENERAL' ? 'В' : room.name.slice(0, 2).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{user ? roomDisplayLabel(room, user.id) : room.name}</p>
<p className="truncate text-xs text-[#667085]">
{room.lastMessage?.isDeleted
? 'Сообщение удалено'
: room.lastMessage?.content ?? 'Нет сообщений'}
</p>
</div>
</button>
))}
</div>
) : (
<p className="px-2 py-2 text-sm text-[#667085]">Чаты пока не созданы</p>
)}
</section>
<section>
<p className="mb-2 px-1 text-xs font-semibold uppercase tracking-wide text-[#667085]">Написать участнику</p>
<div className="space-y-1">
{otherMembers.map((member) => {
const presence = presenceByUserId.get(member.userId);
return (
<button
key={member.id}
type="button"
disabled={openingMemberId === member.userId}
className="flex w-full items-center gap-3 rounded-xl px-2 py-2 text-left transition hover:bg-[#f4f5f8] disabled:opacity-60"
onClick={() => void openMemberChat(member.userId, member.displayName)}
>
<AvatarWithPresence
userId={member.userId}
displayName={member.displayName}
hasAvatar={member.hasAvatar}
token={token}
isVerified={member.isVerified}
verificationIcon={member.verificationIcon}
online={presence?.online}
className="h-9 w-9"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{member.displayName}</p>
<p className={cn('text-xs', presence?.online ? 'text-emerald-600' : 'text-[#667085]')}>
{presence?.online ? 'В сети' : 'Личный чат'}
</p>
</div>
{openingMemberId === member.userId ? <Loader2 className="h-4 w-4 animate-spin text-[#667085]" /> : null}
</button>
);
})}
{!otherMembers.length ? (
<p className="px-2 py-2 text-sm text-[#667085]">Пригласите участников в семью</p>
) : null}
</div>
</section>
<Link
href={`/family/${group!.id}`}
className="mt-3 block rounded-xl bg-[#f4f5f8] px-3 py-2 text-center text-xs font-medium text-[#3390ec] hover:bg-[#eceef4]"
onClick={closeMiniChat}
>
Открыть семью полностью
</Link>
</div>
) : (
<>
<div className="flex-1 space-y-2 overflow-y-auto bg-[#eef1f6] p-3" style={{ maxHeight: 'min(50vh,380px)' }}>
{loadingMessages ? (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-[#667085]">
<Loader2 className="h-4 w-4 animate-spin" />
Загрузка...
</div>
) : messages.length ? (
messages.map((message) => {
const mine = message.senderId === user.id;
return (
<div key={message.id} className={cn('flex', mine ? 'justify-end' : 'justify-start')}>
<div
className={cn(
'max-w-[85%] rounded-[16px] px-3 py-2 text-sm shadow-sm',
mine ? 'bg-[#effdde]' : 'bg-white',
message.isDeleted && 'italic text-[#667085]'
)}
>
{!mine ? <p className="mb-0.5 text-[11px] font-semibold text-[#3390ec]">{message.senderName}</p> : null}
<p>{message.isDeleted ? 'Сообщение удалено' : message.content ?? '…'}</p>
<p className="mt-1 text-[10px] text-[#a8adbc]">{formatMessageTime(message.createdAt)}</p>
</div>
</div>
);
})
) : (
<p className="py-8 text-center text-sm text-[#667085]">Сообщений пока нет</p>
)}
<div ref={messagesEndRef} />
</div>
<div className="flex items-center gap-2 border-t border-[#eceef4] p-3">
<Input
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder="Сообщение"
className="rounded-xl"
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
void handleSend();
}
}}
/>
<Button className="h-10 w-10 shrink-0 rounded-full p-0" disabled={sending || !draft.trim()} onClick={() => void handleSend()}>
{sending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
</Button>
</div>
</>
)}
</div>
) : null}
</>
);
}