fix(chat): автоскролл задач только у низа, картинки с фикс. размером, упоминания по @ и 3+ буквам имени/фамилии
- TaskChat: atBottomRef-гвард (scrollHeight-scrollTop-clientHeight<80) — чужие сообщения больше не дёргают чат вниз, когда пользователь читает историю; свои сообщения и смена задачи скроллят как раньше - ChatAttachments: img w-180 h-180 object-cover вместо max-* — высота строки не меняется при догрузке, measureElement виртуализатора не дёргает список - упоминания: голый @ открывает список сразу; текстовый триггер от 3 букв; сервер ищет ILIKE по имени/отчеству/фамилии/email и отдаёт всех при пустом q; починен мёртвый fallback-резолв (Response без .json()) - groupByDate/flattenGroupedMessages обёрнуты в useMemo
This commit is contained in:
@@ -321,13 +321,16 @@ const TaskChat = ({ taskId }: TaskChatProps) => {
|
|||||||
[messages],
|
[messages],
|
||||||
);
|
);
|
||||||
|
|
||||||
const groupedMessages = groupByDate(displayMessages);
|
const groupedMessages = useMemo(() => groupByDate(displayMessages), [displayMessages]);
|
||||||
|
|
||||||
// Id последнего добавленного сообщения — для анимации появления только его
|
// Id последнего добавленного сообщения — для анимации появления только его
|
||||||
const lastAppendedId = useLastAppendedMessageId(displayMessages, taskId);
|
const lastAppendedId = useLastAppendedMessageId(displayMessages, taskId);
|
||||||
|
|
||||||
// Плоский список для виртуализации: разделители дат + сообщения
|
// Плоский список для виртуализации: разделители дат + сообщения
|
||||||
const chatItems: ChatListItem[] = flattenGroupedMessages(groupedMessages, (m) => `msg-${m.id}`);
|
const chatItems: ChatListItem[] = useMemo(
|
||||||
|
() => flattenGroupedMessages(groupedMessages, (m) => `msg-${m.id}`),
|
||||||
|
[groupedMessages],
|
||||||
|
);
|
||||||
|
|
||||||
// Виртуализация длинных чатов: рендерятся только видимые сообщения + overscan.
|
// Виртуализация длинных чатов: рендерятся только видимые сообщения + overscan.
|
||||||
// Динамическая высота измеряется через measureElement (ResizeObserver).
|
// Динамическая высота измеряется через measureElement (ResizeObserver).
|
||||||
@@ -444,21 +447,39 @@ const TaskChat = ({ taskId }: TaskChatProps) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Пользователь у низа списка — автоскролл на новые сообщения разрешён только тогда
|
||||||
|
const atBottomRef = useRef(true);
|
||||||
|
|
||||||
|
// Отслеживаем, находится ли пользователь у низа списка
|
||||||
|
useEffect(() => {
|
||||||
|
const viewport = scrollAreaRef.current?.querySelector('[data-radix-scroll-area-viewport]');
|
||||||
|
if (!viewport) return;
|
||||||
|
const onScroll = () => {
|
||||||
|
atBottomRef.current = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < 80;
|
||||||
|
};
|
||||||
|
viewport.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
return () => viewport.removeEventListener('scroll', onScroll);
|
||||||
|
}, [taskId, isLoading]);
|
||||||
|
|
||||||
// Автоскролл к последнему сообщению при открытии задачи (даже из кэша react-query)
|
// Автоскролл к последнему сообщению при открытии задачи (даже из кэша react-query)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!taskId) return;
|
if (!taskId) return;
|
||||||
|
atBottomRef.current = true;
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
setTimeout(scrollToBottom, 150);
|
setTimeout(scrollToBottom, 150);
|
||||||
});
|
});
|
||||||
}, [taskId]);
|
}, [taskId]);
|
||||||
|
|
||||||
// Автоскролл к последнему сообщению при загрузке или обновлении сообщений
|
// Автоскролл при новых сообщениях: только если пользователь у низа списка
|
||||||
|
// или последнее сообщение — его собственное (как в мессенджере)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading && messages.length > 0) {
|
if (isLoading || messages.length === 0) return;
|
||||||
|
const last = messages[messages.length - 1];
|
||||||
|
const isMine = last?.author?.id === user?.id;
|
||||||
|
if (!atBottomRef.current && !isMine) return;
|
||||||
// Небольшая задержка чтобы DOM успел обновиться
|
// Небольшая задержка чтобы DOM успел обновиться
|
||||||
setTimeout(scrollToBottom, 100);
|
setTimeout(scrollToBottom, 100);
|
||||||
}
|
}, [messages.length, isLoading, user?.id]);
|
||||||
}, [messages.length, isLoading]);
|
|
||||||
|
|
||||||
// Real-time polling для read receipts сообщений текущего пользователя (исключаем optimistic сообщения с отрицательными ID)
|
// Real-time polling для read receipts сообщений текущего пользователя (исключаем optimistic сообщения с отрицательными ID)
|
||||||
const userMessageIds = user && messages.length > 0
|
const userMessageIds = user && messages.length > 0
|
||||||
@@ -779,7 +800,7 @@ const TaskChat = ({ taskId }: TaskChatProps) => {
|
|||||||
const response = await apiRequest('GET', `/api/mentions/search?q=${encodeURIComponent(mentionQuery)}&limit=10`);
|
const response = await apiRequest('GET', `/api/mentions/search?q=${encodeURIComponent(mentionQuery)}&limit=10`);
|
||||||
return await response.json();
|
return await response.json();
|
||||||
},
|
},
|
||||||
enabled: mentionQuery.length > 1,
|
enabled: showSuggestions && (mentionPosition.hasAt || mentionQuery.length >= 3),
|
||||||
retry: false,
|
retry: false,
|
||||||
staleTime: 0
|
staleTime: 0
|
||||||
});
|
});
|
||||||
@@ -1038,11 +1059,26 @@ const TaskChat = ({ taskId }: TaskChatProps) => {
|
|||||||
const cursorPosition = textarea.selectionStart || 0;
|
const cursorPosition = textarea.selectionStart || 0;
|
||||||
const textBeforeCursor = text.slice(0, cursorPosition);
|
const textBeforeCursor = text.slice(0, cursorPosition);
|
||||||
|
|
||||||
// МГНОВЕННЫЙ ПОИСК БЕЗ @ СИМВОЛА (как в Discord)
|
// Триггер по '@': голый '@' или '@слово' в конце текста перед курсором
|
||||||
// Ищем последнее слово перед курсором (поддержка Unicode)
|
// открывает список сразу (как в мессенджере, /(@(\w*))$/)
|
||||||
const wordMatch = textBeforeCursor.match(/([а-яё\w\u00C0-\u024F\u1E00-\u1EFF]+)$/i);
|
const atMatch = textBeforeCursor.match(/@([а-яё\w\u00C0-\u024F\u1E00-\u1EFF]*)$/i);
|
||||||
|
|
||||||
if (wordMatch && wordMatch[1].length > 1) {
|
if (atMatch) {
|
||||||
|
setMentionQuery(atMatch[1]);
|
||||||
|
setMentionPosition({
|
||||||
|
start: (atMatch.index ?? 0) + 1, // позиция сразу после '@' — символ сохраняется при подстановке
|
||||||
|
end: cursorPosition,
|
||||||
|
hasAt: true
|
||||||
|
});
|
||||||
|
setShowSuggestions(true);
|
||||||
|
setSelectedSuggestionIndex(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Текстовый триггер без '@': последнее слово от 3 букв (поиск по имени и фамилии)
|
||||||
|
const wordMatch = textBeforeCursor.match(/([а-яё\w\u00C0-\u024F\u1E00-\u1EFF]{3,})$/i);
|
||||||
|
|
||||||
|
if (wordMatch) {
|
||||||
const query = wordMatch[1];
|
const query = wordMatch[1];
|
||||||
setMentionQuery(query);
|
setMentionQuery(query);
|
||||||
setMentionPosition({
|
setMentionPosition({
|
||||||
@@ -1205,7 +1241,8 @@ const TaskChat = ({ taskId }: TaskChatProps) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const searchResponse = await apiRequest('GET', `/api/users/search?q=${encodeURIComponent(searchQuery)}&limit=5`);
|
const searchResponse = await apiRequest('GET', `/api/users/search?q=${encodeURIComponent(searchQuery)}&limit=5`);
|
||||||
const foundUsers = (searchResponse as any)?.users || [];
|
const searchData = await searchResponse.json();
|
||||||
|
const foundUsers = searchData?.users || [];
|
||||||
|
|
||||||
if (foundUsers.length > 0) {
|
if (foundUsers.length > 0) {
|
||||||
const user = foundUsers[0];
|
const user = foundUsers[0];
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ export function AttachmentDisplay({
|
|||||||
<img
|
<img
|
||||||
src={href}
|
src={href}
|
||||||
alt={att.name}
|
alt={att.name}
|
||||||
className="max-w-[180px] max-h-[180px] object-cover"
|
className="w-[180px] h-[180px] object-cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -505,9 +505,9 @@ export function registerUserManagementRoutes(router: Router): void {
|
|||||||
try {
|
try {
|
||||||
const { q: query, limit = '10' } = req.query;
|
const { q: query, limit = '10' } = req.query;
|
||||||
|
|
||||||
if (!query || typeof query !== 'string' || query.trim().length === 0) {
|
// Пустой запрос допустим (голый '@' в чате): возвращаем первых пользователей
|
||||||
return res.status(400).json({ success: false, error: 'Поисковый запрос обязателен' });
|
// организации и всех доступных ботов
|
||||||
}
|
const trimmedQuery = typeof query === 'string' ? query.trim() : '';
|
||||||
|
|
||||||
if (!req.organizationId || typeof req.organizationId !== 'number') {
|
if (!req.organizationId || typeof req.organizationId !== 'number') {
|
||||||
return res.status(400).json({ success: false, error: 'Ошибка идентификации организации' });
|
return res.status(400).json({ success: false, error: 'Ошибка идентификации организации' });
|
||||||
@@ -515,8 +515,8 @@ export function registerUserManagementRoutes(router: Router): void {
|
|||||||
|
|
||||||
const searchLimit = Math.min(parseInt(limit as string) || 10, 20);
|
const searchLimit = Math.min(parseInt(limit as string) || 10, 20);
|
||||||
const [users, allBots] = await Promise.all([
|
const [users, allBots] = await Promise.all([
|
||||||
storage.searchUsers(query.trim(), req.organizationId, searchLimit),
|
storage.searchUsers(trimmedQuery, req.organizationId, searchLimit),
|
||||||
storage.searchBots(query.trim(), req.organizationId, searchLimit)
|
storage.searchBots(trimmedQuery, req.organizationId, searchLimit)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const currentUser = req.user!;
|
const currentUser = req.user!;
|
||||||
|
|||||||
@@ -370,6 +370,8 @@ export class SocialStorage extends TasksStorage {
|
|||||||
${users.middleName} ILIKE ${'%' + query + '%'}
|
${users.middleName} ILIKE ${'%' + query + '%'}
|
||||||
OR
|
OR
|
||||||
${users.lastName} ILIKE ${'%' + query + '%'}
|
${users.lastName} ILIKE ${'%' + query + '%'}
|
||||||
|
OR
|
||||||
|
${users.email} ILIKE ${'%' + query + '%'}
|
||||||
)`
|
)`
|
||||||
))
|
))
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
|
|||||||
Reference in New Issue
Block a user