global update and global fix
This commit is contained in:
57
apps/frontend/hooks/use-chat-drag-selection.ts
Normal file
57
apps/frontend/hooks/use-chat-drag-selection.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface UseChatDragSelectionOptions {
|
||||
selectionMode: boolean;
|
||||
enterSelectionMode: (messageId: string) => void;
|
||||
addSelectedMessage: (messageId: string) => void;
|
||||
}
|
||||
|
||||
export function useChatDragSelection({
|
||||
selectionMode,
|
||||
enterSelectionMode,
|
||||
addSelectedMessage
|
||||
}: UseChatDragSelectionOptions) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const visitedRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const startDragSelection = useCallback(
|
||||
(messageId: string) => {
|
||||
setIsDragging(true);
|
||||
visitedRef.current = new Set([messageId]);
|
||||
if (!selectionMode) {
|
||||
enterSelectionMode(messageId);
|
||||
} else {
|
||||
addSelectedMessage(messageId);
|
||||
}
|
||||
},
|
||||
[addSelectedMessage, enterSelectionMode, selectionMode]
|
||||
);
|
||||
|
||||
const handleDragEnterMessage = useCallback(
|
||||
(messageId: string) => {
|
||||
if (!isDragging || visitedRef.current.has(messageId)) return;
|
||||
visitedRef.current.add(messageId);
|
||||
addSelectedMessage(messageId);
|
||||
},
|
||||
[addSelectedMessage, isDragging]
|
||||
);
|
||||
|
||||
const endDragSelection = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
visitedRef.current.clear();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('mouseup', endDragSelection);
|
||||
return () => window.removeEventListener('mouseup', endDragSelection);
|
||||
}, [endDragSelection]);
|
||||
|
||||
return {
|
||||
isDragging,
|
||||
startDragSelection,
|
||||
handleDragEnterMessage,
|
||||
endDragSelection
|
||||
};
|
||||
}
|
||||
106
apps/frontend/hooks/use-chat-message-selection.ts
Normal file
106
apps/frontend/hooks/use-chat-message-selection.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { ChatMessage } from '@/lib/api';
|
||||
import { isSelectableMessage } from '@/lib/chat-message-utils';
|
||||
|
||||
export function useChatMessageSelection(messages: ChatMessage[]) {
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [selectedMessageIds, setSelectedMessageIds] = useState<string[]>([]);
|
||||
const anchorIndexRef = useRef<number | null>(null);
|
||||
|
||||
const exitSelectionMode = useCallback(() => {
|
||||
setSelectionMode(false);
|
||||
setSelectedMessageIds([]);
|
||||
anchorIndexRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectionMode) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
exitSelectionMode();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [exitSelectionMode, selectionMode]);
|
||||
|
||||
const enterSelectionMode = useCallback((messageId?: string) => {
|
||||
if (messageId) {
|
||||
const message = messages.find((item) => item.id === messageId);
|
||||
if (!message || !isSelectableMessage(message)) return;
|
||||
}
|
||||
setSelectionMode(true);
|
||||
setSelectedMessageIds(messageId ? [messageId] : []);
|
||||
anchorIndexRef.current = messageId ? messages.findIndex((item) => item.id === messageId) : null;
|
||||
}, [messages]);
|
||||
|
||||
const addSelectedMessage = useCallback((messageId: string) => {
|
||||
const message = messages.find((item) => item.id === messageId);
|
||||
if (!message || !isSelectableMessage(message)) return;
|
||||
setSelectionMode(true);
|
||||
setSelectedMessageIds((current) => (current.includes(messageId) ? current : [...current, messageId]));
|
||||
anchorIndexRef.current = messages.findIndex((item) => item.id === messageId);
|
||||
}, [messages]);
|
||||
|
||||
const toggleSelectedMessage = useCallback((messageId: string) => {
|
||||
setSelectedMessageIds((current) => {
|
||||
const next = current.includes(messageId) ? current.filter((id) => id !== messageId) : [...current, messageId];
|
||||
return next;
|
||||
});
|
||||
anchorIndexRef.current = messages.findIndex((item) => item.id === messageId);
|
||||
}, [messages]);
|
||||
|
||||
const handleSelectionPointer = useCallback(
|
||||
(messageId: string, event: { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean }) => {
|
||||
const index = messages.findIndex((item) => item.id === messageId);
|
||||
if (index < 0) return false;
|
||||
|
||||
const multiKey = Boolean(event.ctrlKey || event.metaKey);
|
||||
if (multiKey) {
|
||||
if (!selectionMode) {
|
||||
enterSelectionMode(messageId);
|
||||
} else {
|
||||
toggleSelectedMessage(messageId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.shiftKey) {
|
||||
if (!selectionMode) {
|
||||
enterSelectionMode(messageId);
|
||||
}
|
||||
const anchor = anchorIndexRef.current ?? index;
|
||||
const start = Math.min(anchor, index);
|
||||
const end = Math.max(anchor, index);
|
||||
const rangeIds = messages
|
||||
.slice(start, end + 1)
|
||||
.filter((item) => isSelectableMessage(item))
|
||||
.map((item) => item.id);
|
||||
setSelectedMessageIds(rangeIds);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (selectionMode) {
|
||||
toggleSelectedMessage(messageId);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
[enterSelectionMode, messages, selectionMode, toggleSelectedMessage]
|
||||
);
|
||||
|
||||
return {
|
||||
selectionMode,
|
||||
selectedMessageIds,
|
||||
setSelectedMessageIds,
|
||||
enterSelectionMode,
|
||||
exitSelectionMode,
|
||||
toggleSelectedMessage,
|
||||
addSelectedMessage,
|
||||
handleSelectionPointer
|
||||
};
|
||||
}
|
||||
44
apps/frontend/hooks/use-chat-typing.ts
Normal file
44
apps/frontend/hooks/use-chat-typing.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
type Typer = { userId: string; userName: string; expiresAt: number };
|
||||
|
||||
export function useChatTyping(currentUserId?: string) {
|
||||
const [typers, setTypers] = useState<Typer[]>([]);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
|
||||
const pruneTypers = useCallback(() => {
|
||||
const now = Date.now();
|
||||
setTypers((current) => current.filter((item) => item.expiresAt > now));
|
||||
}, []);
|
||||
|
||||
const registerTyper = useCallback(
|
||||
(userId: string, userName: string) => {
|
||||
if (!userId || userId === currentUserId) return;
|
||||
const expiresAt = Date.now() + 3000;
|
||||
setTypers((current) => {
|
||||
const without = current.filter((item) => item.userId !== userId);
|
||||
return [...without, { userId, userName, expiresAt }];
|
||||
});
|
||||
},
|
||||
[currentUserId]
|
||||
);
|
||||
|
||||
const clearTypers = useCallback(() => {
|
||||
setTypers([]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
timerRef.current = window.setInterval(pruneTypers, 500);
|
||||
return () => {
|
||||
if (timerRef.current) window.clearInterval(timerRef.current);
|
||||
};
|
||||
}, [pruneTypers]);
|
||||
|
||||
return {
|
||||
typers: typers.map(({ userId, userName }) => ({ userId, userName })),
|
||||
registerTyper,
|
||||
clearTypers
|
||||
};
|
||||
}
|
||||
145
apps/frontend/hooks/use-e2e-chat.ts
Normal file
145
apps/frontend/hooks/use-e2e-chat.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { ChatMessage, ChatRoom, fetchUserE2EPublicKey, setUserE2EPublicKey } from '@/lib/api';
|
||||
import { ensureE2EKeyPair } from '@/lib/e2e-crypto';
|
||||
import {
|
||||
decryptE2EPayload,
|
||||
encryptE2EPayload,
|
||||
readableE2EPayload,
|
||||
type E2EMessagePayload
|
||||
} from '@/lib/e2e-chat';
|
||||
|
||||
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 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) return;
|
||||
void (async () => {
|
||||
try {
|
||||
const { publicKey } = await ensureE2EKeyPair(options.userId!);
|
||||
await setUserE2EPublicKey(options.userId!, publicKey, options.token);
|
||||
} catch {
|
||||
// фоновая инициализация E2E
|
||||
}
|
||||
})();
|
||||
}, [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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user