Add for leave family

This commit is contained in:
lendry
2026-06-26 23:41:20 +03:00
parent f1bba24faa
commit 886b7e9ade
9 changed files with 489 additions and 21 deletions

View File

@@ -12,6 +12,7 @@ import {
FileText,
ImageIcon,
Loader2,
LogOut,
Mic,
Paperclip,
Pin,
@@ -84,6 +85,7 @@ import {
fetchFamilyPresence,
forwardChatMessages,
getApiErrorMessage,
leaveFamilyGroup,
markChatRoomRead,
removeChatRoomMember,
removeFamilyMember,
@@ -278,6 +280,9 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
const [presenceMembers, setPresenceMembers] = useState<FamilyPresenceMember[]>([]);
const [deleteFamilyDialogOpen, setDeleteFamilyDialogOpen] = useState(false);
const [leaveFamilyDialogOpen, setLeaveFamilyDialogOpen] = useState(false);
const [leavingFamily, setLeavingFamily] = useState(false);
const [selectedNewOwnerUserId, setSelectedNewOwnerUserId] = useState<string | null>(null);
const [deletingFamily, setDeletingFamily] = useState(false);
const [deleteMessagesDialogOpen, setDeleteMessagesDialogOpen] = useState(false);
const [pendingDeleteMessageIds, setPendingDeleteMessageIds] = useState<string[]>([]);
@@ -381,6 +386,16 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
);
const myMembership = activeRoom?.members.find((member) => member.userId === user?.id);
const isFamilyOwner = group?.ownerId === user?.id;
const humanMembers = useMemo(
() => (group?.members ?? []).filter((member) => !member.isBot && member.role !== 'bot'),
[group?.members]
);
const otherHumanMembers = useMemo(
() => humanMembers.filter((member) => member.userId !== user?.id),
[humanMembers, user?.id]
);
const ownerMustTransfer = Boolean(isFamilyOwner && otherHumanMembers.length > 0);
const ownerLeaveDeletesFamily = Boolean(isFamilyOwner && otherHumanMembers.length === 0);
const isChatCreator = activeRoom?.createdById === user?.id;
const canManageChatMembers = Boolean(isFamilyOwner || isChatCreator || activeRoomIsBot);
const canManageActiveBot = Boolean(
@@ -609,6 +624,31 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
if (event.type === 'family_group_deleted' && payload?.groupId === groupId) {
showToast('Семья была удалена');
router.push('/family');
return;
}
if (event.type === 'family_left_self' && payload?.groupId === groupId) {
router.push('/family');
return;
}
if (event.type === 'family_member_left' && payload?.groupId === groupId) {
const userName = typeof payload?.userName === 'string' ? payload.userName : 'Участник';
showToast(`${userName} покинул(а) семью`);
void loadGroup();
return;
}
if (event.type === 'family_member_removed' && payload?.groupId === groupId) {
const userName = typeof payload?.userName === 'string' ? payload.userName : 'Участник';
showToast(`${userName} удалён(а) из семьи`);
void loadGroup();
return;
}
if (event.type === 'family_ownership_transferred' && payload?.groupId === groupId) {
showToast('Вы стали главой семьи');
void loadGroup();
}
});
}, [
@@ -821,12 +861,6 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
if (!token) return;
try {
await removeFamilyMember(memberId, token);
const removedMember = group?.members?.find((item) => item.id === memberId);
if (removedMember?.userId === user?.id) {
showToast('Вы вышли из семьи');
router.push('/family');
return;
}
await loadGroup();
setMembersOpen(false);
setFamilyMembersOpen(false);
@@ -836,6 +870,35 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
}
}
function openLeaveFamilyDialog() {
setSelectedNewOwnerUserId(otherHumanMembers[0]?.userId ?? null);
setLeaveFamilyDialogOpen(true);
}
async function confirmLeaveFamily() {
if (!token) return;
if (ownerMustTransfer && !selectedNewOwnerUserId) {
showToast('Выберите нового главу семьи');
return;
}
setLeavingFamily(true);
try {
const result = await leaveFamilyGroup(
groupId,
token,
ownerMustTransfer ? selectedNewOwnerUserId ?? undefined : undefined
);
setLeaveFamilyDialogOpen(false);
setFamilyMembersOpen(false);
showToast(result.deleted ? 'Семья удалена' : 'Вы покинули семью');
router.push('/family');
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось покинуть семью') ?? 'Ошибка');
} finally {
setLeavingFamily(false);
}
}
async function createSecretChatWithMember(memberUserId: string) {
if (!token) return;
try {
@@ -1456,6 +1519,11 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
<div className="flex items-center justify-between gap-2">
<p className="flex min-w-0 items-center gap-1 truncate font-medium">
<span className="truncate">{user ? roomDisplayLabel(room, user.id) : room.name}</span>
{room.peerLeftFamily ? (
<span className="shrink-0 rounded-md bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700">
не в семье
</span>
) : null}
{room.isPinned ? <Pin className="h-3.5 w-3.5 shrink-0 fill-[#8a8f9e] text-[#8a8f9e]" /> : null}
</p>
{room.lastMessage ? (
@@ -1528,9 +1596,19 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
{activeRoomIsBot ? (
<>🤖 Бот · {activeRoom.members.length} участников</>
) : activeRoomIsE2E ? (
<>🔒 E2E · секретный чат</>
<>
🔒 E2E · секретный чат
{activeRoom.peerLeftFamily ? (
<span className="ml-1 text-amber-600">· собеседник не в семье</span>
) : null}
</>
) : activeRoom.type === 'DIRECT' ? (
<>Личный чат · {activeRoom.members.length} участников</>
<>
Личный чат · {activeRoom.members.length} участников
{activeRoom.peerLeftFamily ? (
<span className="ml-1 text-amber-600">· собеседник не в семье</span>
) : null}
</>
) : (
<>
{onlineCount > 0 ? (
@@ -2220,8 +2298,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
<div className="mt-3 max-h-[420px] space-y-2 overflow-y-auto">
{(group?.members ?? []).map((member) => {
const isSelf = member.userId === user?.id;
const canRemove =
member.role !== 'owner' && (isFamilyOwner || isSelf);
const canKick = !isSelf && isFamilyOwner && member.role !== 'owner' && !member.isBot;
const presence = presenceByUserId.get(member.userId);
return (
@@ -2272,7 +2349,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
🔒 E2E
</Button>
) : null}
{canRemove ? (
{canKick ? (
<Button
size="sm"
variant="ghost"
@@ -2282,7 +2359,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
void handleRemoveFromFamily(member.id);
}}
>
{isSelf ? 'Выйти' : 'Удалить'}
Удалить
</Button>
) : null}
</div>
@@ -2296,6 +2373,17 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
<UserPlus className="mr-2 h-4 w-4" />
Пригласить в семью
</Button>
<Button
className="mt-2 w-full rounded-xl"
variant="secondary"
onClick={() => {
setFamilyMembersOpen(false);
openLeaveFamilyDialog();
}}
>
<LogOut className="mr-2 h-4 w-4" />
Покинуть семью
</Button>
{isFamilyOwner ? (
<Button
className="mt-2 w-full rounded-xl bg-red-600 hover:bg-red-700"
@@ -2311,6 +2399,87 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
</DialogContent>
</Dialog>
<Dialog open={leaveFamilyDialogOpen} onOpenChange={setLeaveFamilyDialogOpen}>
<DialogContent className="rounded-[28px] sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>Покинуть семью «{group?.name}»?</DialogTitle>
</DialogHeader>
{ownerMustTransfer ? (
<>
<p className="text-sm leading-relaxed text-[#667085]">
Вы создатель семьи. Перед выходом передайте управление другому участнику он станет новым главой
семьи.
</p>
<div className="mt-4 max-h-[280px] space-y-2 overflow-y-auto">
{otherHumanMembers.map((member) => {
const selected = selectedNewOwnerUserId === member.userId;
return (
<button
key={member.id}
type="button"
onClick={() => setSelectedNewOwnerUserId(member.userId)}
className={cn(
'flex w-full items-center gap-3 rounded-2xl px-3 py-3 text-left transition',
selected ? 'bg-[#3390ec]/10 ring-2 ring-[#3390ec]/40' : 'bg-[#f4f5f8] hover:bg-[#eceef4]'
)}
>
<AvatarWithPresence
userId={member.userId}
displayName={member.displayName}
hasAvatar={member.hasAvatar}
token={token}
isVerified={member.isVerified}
verificationIcon={member.verificationIcon}
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]">{familyMemberRoleLabel(member.role)}</p>
</div>
{selected ? <Check className="h-5 w-5 shrink-0 text-[#3390ec]" /> : null}
</button>
);
})}
</div>
</>
) : ownerLeaveDeletesFamily ? (
<p className="text-sm leading-relaxed text-[#667085]">
В семье больше нет других участников. Семья и все чаты будут полностью удалены без возможности
восстановления.
</p>
) : (
<p className="text-sm leading-relaxed text-[#667085]">
Семья исчезнет из вашего профиля вместе со всеми чатами. У остальных участников переписка с вами
сохранится с пометкой, что вы больше не состоите в семье.
</p>
)}
<div className="mt-6 flex gap-3">
<Button
variant="secondary"
className="flex-1"
disabled={leavingFamily}
onClick={() => setLeaveFamilyDialogOpen(false)}
>
Отмена
</Button>
<Button
className="flex-1 bg-red-600 hover:bg-red-700"
disabled={leavingFamily || (ownerMustTransfer && !selectedNewOwnerUserId)}
onClick={() => void confirmLeaveFamily()}
>
{leavingFamily ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Выходим...
</>
) : (
'Покинуть семью'
)}
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={deleteFamilyDialogOpen} onOpenChange={setDeleteFamilyDialogOpen}>
<DialogContent className="rounded-[28px] sm:max-w-[440px]">
<DialogHeader>
@@ -2366,7 +2535,14 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
className="h-10 w-10"
/>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{member.displayName}</p>
<p className="truncate font-medium">
{member.displayName}
{member.inFamily === false ? (
<span className="ml-1.5 rounded-md bg-[#eceef4] px-1.5 py-0.5 text-[10px] font-medium text-[#667085]">
не в семье
</span>
) : null}
</p>
<p className="text-xs text-[#667085]">{memberRoleLabel(member)}</p>
<p className={cn('text-xs', presence?.online ? 'font-medium text-emerald-600' : 'text-[#a8adbc]')}>
{presence ? formatPresenceStatus(presence) : 'Статус неизвестен'}

View File

@@ -859,6 +859,7 @@ export interface ChatRoomMember {
pinnedAt?: string;
familyRole?: string;
isChatCreator?: boolean;
inFamily?: boolean;
}
export interface ChatRoom {
@@ -874,6 +875,7 @@ export interface ChatRoom {
updatedAt: string;
isPinned?: boolean;
pinnedAt?: string;
peerLeftFamily?: boolean;
members: ChatRoomMember[];
lastMessage?: ChatMessage;
}
@@ -978,6 +980,17 @@ export async function removeFamilyMember(memberId: string, token?: string | null
return apiFetch<{ count: number }>(`/family/members/${memberId}`, { method: 'DELETE' }, token);
}
export async function leaveFamilyGroup(groupId: string, token?: string | null, newOwnerUserId?: string) {
return apiFetch<{ count: number; deleted?: boolean }>(
`/family/groups/${groupId}/leave`,
{
method: 'POST',
body: JSON.stringify(newOwnerUserId ? { newOwnerUserId } : {})
},
token
);
}
export async function fetchChatMessages(roomId: string, token?: string | null, beforeMessageId?: string) {
const query = beforeMessageId ? `?beforeMessageId=${beforeMessageId}` : '';
return apiFetch<{ messages?: ChatMessage[] }>(`/chat/rooms/${roomId}/messages${query}`, {}, token);