153 lines
4.7 KiB
TypeScript
153 lines
4.7 KiB
TypeScript
'use client';
|
||
|
||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||
import { useAuth } from '@/components/id/auth-provider';
|
||
import { ChatMessage, ChatRoom, fetchUserE2EPublicKey, setUserE2EPublicKey } from '@/lib/api';
|
||
import { ensureE2EKeyPair } from '@/lib/e2e-crypto';
|
||
import {
|
||
decryptE2EPayload,
|
||
encryptE2EPayload,
|
||
readableE2EPayload,
|
||
type E2EMessagePayload
|
||
} from '@/lib/e2e-chat';
|
||
|
||
const publishedE2EKeys = new Set<string>();
|
||
|
||
export function resolveChatPeerUserId(room: ChatRoom | null | undefined, userId: string | undefined) {
|
||
if (!room || !userId) return null;
|
||
const fromMembers = room.members.find((member) => member.userId !== userId)?.userId;
|
||
if (fromMembers) return fromMembers;
|
||
if (room.peerUserId && room.peerUserId !== userId) return room.peerUserId;
|
||
return null;
|
||
}
|
||
|
||
export function getE2EMessageText(options: {
|
||
message: ChatMessage;
|
||
isE2E: boolean;
|
||
peerE2eKey: string | null;
|
||
peerKeyLoading: boolean;
|
||
e2ePayload?: E2EMessagePayload | null;
|
||
e2eError?: string;
|
||
}) {
|
||
const { message, isE2E, peerE2eKey, peerKeyLoading, e2ePayload, e2eError } = options;
|
||
if (!message.isEncrypted) {
|
||
return message.content ?? '';
|
||
}
|
||
if (e2ePayload) {
|
||
return readableE2EPayload(e2ePayload);
|
||
}
|
||
if (e2eError) {
|
||
return e2eError;
|
||
}
|
||
if (isE2E && peerKeyLoading) {
|
||
return '🔒 Расшифровка...';
|
||
}
|
||
if (isE2E && !peerE2eKey) {
|
||
return '🔒 Собеседник не опубликовал ключ шифрования';
|
||
}
|
||
return '🔒 Расшифровка...';
|
||
}
|
||
|
||
export function useE2EChat(options: {
|
||
room: ChatRoom | null | undefined;
|
||
messages: ChatMessage[];
|
||
userId?: string;
|
||
token?: string | null;
|
||
}) {
|
||
const { isLoading, isApiReady } = useAuth();
|
||
const isE2E = options.room?.type === 'E2E';
|
||
const peerUserId = useMemo(
|
||
() => resolveChatPeerUserId(options.room, options.userId),
|
||
[options.room, options.userId]
|
||
);
|
||
|
||
const [peerE2eKey, setPeerE2eKey] = useState<string | null>(null);
|
||
const [peerKeyLoading, setPeerKeyLoading] = useState(false);
|
||
const [e2ePayloads, setE2ePayloads] = useState<Record<string, E2EMessagePayload | null>>({});
|
||
const [e2eErrors, setE2eErrors] = useState<Record<string, string>>({});
|
||
|
||
useEffect(() => {
|
||
if (!options.userId || !options.token || isLoading || !isApiReady) return;
|
||
void (async () => {
|
||
try {
|
||
const { publicKey } = await ensureE2EKeyPair(options.userId!);
|
||
const cacheKey = `${options.userId}:${publicKey}`;
|
||
if (publishedE2EKeys.has(cacheKey)) return;
|
||
await setUserE2EPublicKey(options.userId!, publicKey, options.token);
|
||
publishedE2EKeys.add(cacheKey);
|
||
} catch {
|
||
// фоновая инициализация E2E
|
||
}
|
||
})();
|
||
}, [isApiReady, isLoading, options.token, options.userId]);
|
||
|
||
useEffect(() => {
|
||
if (!isE2E || !peerUserId || !options.token) {
|
||
setPeerE2eKey(null);
|
||
setPeerKeyLoading(false);
|
||
return;
|
||
}
|
||
setPeerKeyLoading(true);
|
||
void fetchUserE2EPublicKey(peerUserId, options.token)
|
||
.then((response) => setPeerE2eKey(response.e2ePublicKey ?? null))
|
||
.catch(() => setPeerE2eKey(null))
|
||
.finally(() => setPeerKeyLoading(false));
|
||
}, [isE2E, options.token, peerUserId]);
|
||
|
||
useEffect(() => {
|
||
if (!isE2E || !peerE2eKey || !options.userId) {
|
||
setE2ePayloads({});
|
||
setE2eErrors({});
|
||
return;
|
||
}
|
||
|
||
let cancelled = false;
|
||
void (async () => {
|
||
const nextPayloads: Record<string, E2EMessagePayload | null> = {};
|
||
const nextErrors: Record<string, string> = {};
|
||
|
||
for (const message of options.messages) {
|
||
if (!message.isEncrypted || !message.content) continue;
|
||
const payload = await decryptE2EPayload(options.userId!, peerE2eKey, message.content);
|
||
if (payload) {
|
||
nextPayloads[message.id] = payload;
|
||
} else {
|
||
nextErrors[message.id] = '🔒 Не удалось расшифровать сообщение';
|
||
}
|
||
}
|
||
|
||
if (!cancelled) {
|
||
setE2ePayloads(nextPayloads);
|
||
setE2eErrors(nextErrors);
|
||
}
|
||
})();
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [isE2E, options.messages, options.userId, peerE2eKey]);
|
||
|
||
const encryptOutgoing = useCallback(
|
||
async (payload: Parameters<typeof encryptE2EPayload>[2]) => {
|
||
if (!isE2E || !options.userId) {
|
||
throw new Error('E2E недоступен');
|
||
}
|
||
if (!peerE2eKey) {
|
||
throw new Error('Собеседник ещё не опубликовал ключ шифрования');
|
||
}
|
||
return encryptE2EPayload(options.userId, peerE2eKey, payload);
|
||
},
|
||
[isE2E, options.userId, peerE2eKey]
|
||
);
|
||
|
||
return {
|
||
isE2E,
|
||
peerUserId,
|
||
peerE2eKey,
|
||
peerKeyLoading,
|
||
e2ePayloads,
|
||
e2eErrors,
|
||
encryptOutgoing
|
||
};
|
||
}
|