13 Commits
Author SHA1 Message Date
Халимов Рустам 67d5764f6e Убрал аватары и имена внутри чата личных чатов 2026-04-20 23:23:29 +03:00
Халимов Рустам 786eaffb33 Fix unread divider: update sessionUnreadRef when messages are marked as read 2026-04-20 23:21:50 +03:00
Халимов Рустам 4940f1212f Fix read receipts: mark visible messages as read immediately 2026-04-20 23:20:39 +03:00
Халимов Рустам c166f1d186 Fix read receipts: join chat group when activeChat changes 2026-04-20 23:14:31 +03:00
Халимов Рустам 71b3d2b491 Fix IMessageRepository import in LeaveOrDeleteChat.cs 2026-04-20 23:04:42 +03:00
Халимов Рустам a5d40f28c8 Fix MessageBubble.tsx JSX syntax errors 2026-04-20 23:00:30 +03:00
Халимов Рустам 9bfc5555bc Правки 2026-04-20 22:58:23 +03:00
Халимов Рустам 7eea8ff6d1 Правка уведомления 2026-04-20 22:48:25 +03:00
Халимов Рустам 72325f48e5 Убрал аватары и имена внутри чата личных чатов 2026-04-20 22:37:51 +03:00
Халимов Рустам 2b51375fbf Отступы в баблах 2026-04-20 22:37:25 +03:00
Халимов Рустам c92289f074 Вставка и перетаскивание в поле ввода 2026-04-20 22:35:00 +03:00
Халимов Рустам 33ea792941 Дубликат печати, разметка 2026-04-20 22:34:27 +03:00
Халимов Рустам 63fc0e197b Уведомление об удалении чата 2026-04-20 22:31:17 +03:00
7 changed files with 749 additions and 713 deletions
@@ -1,13 +1,15 @@
using System; using System;
using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Knot.Shared.Kernel;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Conversations.Application.Abstractions; using Knot.Contracts.Conversations.Application.Abstractions;
using MediatR; using Knot.Contracts.Conversations.Domain;
using System.Linq;
using Knot.Contracts.Messaging.Application.Abstractions; using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Modules.Conversations.Infrastructure.SignalR;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage; using Knot.Shared.Kernel.Storage;
using MediatR;
using Microsoft.AspNetCore.SignalR;
namespace Knot.Modules.Conversations.Application.Chats.LeaveOrDelete; namespace Knot.Modules.Conversations.Application.Chats.LeaveOrDelete;
@@ -19,17 +21,21 @@ internal sealed class LeaveOrDeleteChatCommandHandler : ICommandHandler<LeaveOrD
private readonly IMessageRepository _messageRepository; private readonly IMessageRepository _messageRepository;
private readonly IFileStorageService _fileStorage; private readonly IFileStorageService _fileStorage;
private readonly IChatsUnitOfWork _uow; private readonly IChatsUnitOfWork _uow;
private readonly IHubContext<ChatHub> _hubContext;
public LeaveOrDeleteChatCommandHandler( public LeaveOrDeleteChatCommandHandler(
IChatRepository chatRepository, IChatRepository chatRepository,
IMessageRepository messageRepository, IMessageRepository messageRepository,
IFileStorageService fileStorage, IFileStorageService fileStorage,
IChatsUnitOfWork uow) IChatsUnitOfWork uow,
IHubContext<ChatHub> hubContext)
{ {
_chatRepository = chatRepository; _chatRepository = chatRepository;
_messageRepository = messageRepository; _messageRepository = messageRepository;
_fileStorage = fileStorage; _fileStorage = fileStorage;
_uow = uow; _uow = uow;
_hubContext = hubContext;
} }
public async Task<Result<SuccessResponse>> Handle(LeaveOrDeleteChatCommand request, CancellationToken cancellationToken) public async Task<Result<SuccessResponse>> Handle(LeaveOrDeleteChatCommand request, CancellationToken cancellationToken)
@@ -58,6 +64,14 @@ internal sealed class LeaveOrDeleteChatCommandHandler : ICommandHandler<LeaveOrD
// DELETE ALL MESSAGES AND FILES FIRST // DELETE ALL MESSAGES AND FILES FIRST
await DeleteChatMediaAndMessagesAsync(chat.Id, cancellationToken); await DeleteChatMediaAndMessagesAsync(chat.Id, cancellationToken);
_chatRepository.Remove(chat); _chatRepository.Remove(chat);
// Notify all remaining members that the chat was deleted
foreach (var member in chat.Members)
{
await _hubContext.Clients.User(member.UserId.ToString())
.SendAsync("chat_deleted", chat.Id.ToString(), cancellationToken);
}
} }
await _uow.SaveChangesAsync(cancellationToken); await _uow.SaveChangesAsync(cancellationToken);
@@ -67,7 +81,8 @@ internal sealed class LeaveOrDeleteChatCommandHandler : ICommandHandler<LeaveOrD
private async Task DeleteChatMediaAndMessagesAsync(Guid chatId, CancellationToken ct) private async Task DeleteChatMediaAndMessagesAsync(Guid chatId, CancellationToken ct)
{ {
try try
{ {
// Get all messages directly from Mongo (not paged) // Get all messages directly from Mongo (not paged)
var messages = await _messageRepository.GetChatMessagesAsync(chatId, int.MaxValue, 0, ct); var messages = await _messageRepository.GetChatMessagesAsync(chatId, int.MaxValue, 0, ct);
+4 -2
View File
@@ -194,7 +194,8 @@ const translations = {
clearChatConfirm: 'Очистить историю чата для себя? Собеседник сохранит свою историю.', clearChatConfirm: 'Очистить историю чата для себя? Собеседник сохранит свою историю.',
clearHistory: 'Очистить историю', clearHistory: 'Очистить историю',
clearHistoryConfirm: 'Очистить историю?', clearHistoryConfirm: 'Очистить историю?',
deleteChatConfirm: 'Удалить чат? Это действие нельзя отменить.', deleteChatConfirm: 'Удалить чат? Это действие нельзя отменить. Чат будет удалён у всех участников.',
deleteGroupChatConfirm: 'Удалить чат? Это действие нельзя отменить. Чат будет удалён у всех участников.',
pinChat: 'Закрепить чат', pinChat: 'Закрепить чат',
unpinChat: 'Открепить чат', unpinChat: 'Открепить чат',
chatCleared: 'Очищено', chatCleared: 'Очищено',
@@ -574,7 +575,8 @@ const translations = {
clearChatConfirm: 'Clear chat history for yourself? The other person will keep their history.', clearChatConfirm: 'Clear chat history for yourself? The other person will keep their history.',
clearHistory: 'Clear history', clearHistory: 'Clear history',
clearHistoryConfirm: 'Clear history?', clearHistoryConfirm: 'Clear history?',
deleteChatConfirm: 'Delete this chat? This action cannot be undone.', deleteChatConfirm: 'Delete this chat? This action cannot be undone. The chat will be removed for all participants.',
deleteGroupChatConfirm: 'Delete this chat? This action cannot be undone. The chat will be removed for all participants.',
pinChat: 'Pin chat', pinChat: 'Pin chat',
unpinChat: 'Unpin chat', unpinChat: 'Unpin chat',
chatCleared: 'Chat cleared', chatCleared: 'Chat cleared',
@@ -17,7 +17,7 @@ export default function GlobalNavBar({ activeTab, onTabChange }: GlobalNavBarPro
return ( return (
<nav <nav
className="lg:fixed lg:left-0 lg:top-0 lg:h-[100dvh] lg:w-20 w-full h-16 fixed bottom-0 left-0 bg-surface-container-low border-t lg:border-t-0 lg:border-r border-white/5 flex lg:flex-col flex-row items-center justify-around lg:justify-start lg:py-8 lg:gap-4 z-50 transition-all safe-area-bottom" className="lg:fixed lg:left-0 lg:top-0 lg:h-[100dvh] lg:w-20 w-full h-16 fixed bottom-0 left-0 bg-surface-container-low border-t lg:border-t-0 lg:border-r border-white/5 flex lg:flex-col flex-row items-center justify-around lg:justify-start lg:pt-8 lg:pb-14 lg:gap-4 z-50 transition-all safe-area-bottom"
> >
<div className="hidden lg:flex mb-10 flex-col items-center"> <div className="hidden lg:flex mb-10 flex-col items-center">
<span className="text-2xl font-black text-primary tracking-tighter italic knot-logo-spin">Knot</span> <span className="text-2xl font-black text-primary tracking-tighter italic knot-logo-spin">Knot</span>
@@ -46,7 +46,7 @@ export default function GlobalNavBar({ activeTab, onTabChange }: GlobalNavBarPro
</div> </div>
<div <div
className="lg:mt-auto group cursor-pointer relative flex items-center justify-center px-4 lg:px-0" className="lg:mt-auto lg:mb-4 group cursor-pointer relative flex items-center justify-center px-4 lg:px-0"
onClick={() => onTabChange('settings')} onClick={() => onTabChange('settings')}
> >
<div className="absolute -inset-1 bg-primary/20 rounded-2xl opacity-0 group-hover:opacity-100 blur transition-opacity" /> <div className="absolute -inset-1 bg-primary/20 rounded-2xl opacity-0 group-hover:opacity-100 blur transition-opacity" />
@@ -65,6 +65,8 @@ export default function ChatPage() {
const [activeTab, setActiveTab] = useState('chats'); const [activeTab, setActiveTab] = useState('chats');
const { t } = useLang(); const { t } = useLang();
const activeChat = useChatStore((state) => state.activeChat);
useEffect(() => { useEffect(() => {
groupCallOpenRef.current = groupCallOpen; groupCallOpenRef.current = groupCallOpen;
groupCallChatIdRef.current = groupCallChatId; groupCallChatIdRef.current = groupCallChatId;
@@ -335,6 +337,16 @@ export default function ChatPage() {
}; };
}, [user?.id]); }, [user?.id]);
// Join chat group when activeChat changes
useEffect(() => {
if (activeChat) {
const socket = getSocket();
if (socket) {
socket.emit('join_chat', activeChat);
}
}
}, [activeChat]);
const handleStartCall = (targetUser: UserBasic, type: 'voice' | 'video') => { const handleStartCall = (targetUser: UserBasic, type: 'voice' | 'video') => {
setCallTarget(targetUser); setCallTarget(targetUser);
setCallType(type); setCallType(type);
@@ -371,8 +383,6 @@ export default function ChatPage() {
setGroupCallOpen(false); setGroupCallOpen(false);
}; };
const activeChat = useChatStore((state) => state.activeChat);
return ( return (
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
@@ -98,13 +98,13 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
NotificationStore.useNotificationStore.getState().addNotification('info', (t as any)('searchingHistory') || 'Searching message in history...'); NotificationStore.useNotificationStore.getState().addNotification('info', (t as any)('searchingHistory') || 'Searching message in history...');
const chatStore = useChatStore.getState(); const chatStore = useChatStore.getState();
if (sequenceId !== undefined) { if (sequenceId !== undefined) {
await chatStore.jumpToMessage(activeChat, sequenceId); await chatStore.jumpToMessage(activeChat, sequenceId);
// Wait a bit for React to render // Wait a bit for React to render
setTimeout(() => { setTimeout(() => {
if (!tryScroll()) { if (!tryScroll()) {
NotificationStore.useNotificationStore.getState().addNotification('warning', (t as any)('messageNotFound') || 'Message not found'); NotificationStore.useNotificationStore.getState().addNotification('warning', (t as any)('messageNotFound') || 'Message not found');
} }
}, 300); }, 300);
} else { } else {
@@ -137,25 +137,25 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
useEffect(() => { useEffect(() => {
if (!chat?.isImporting || !chat?.importJobId) { if (!chat?.isImporting || !chat?.importJobId) {
setImportStatus(null); setImportStatus(null);
return; return;
} }
const poll = async () => { const poll = async () => {
try { try {
const data = await httpClient.request<any>(`/import/telegram/status/${chat.importJobId}`); const data = await httpClient.request<any>(`/import/telegram/status/${chat.importJobId}`);
setImportStatus({ processed: data.processedMessages, total: data.totalMessages, status: data.status }); setImportStatus({ processed: data.processedMessages, total: data.totalMessages, status: data.status });
if (data.status === 'Completed' || data.status === 'Failed') { if (data.status === 'Completed' || data.status === 'Failed') {
setImportStatus(null); setImportStatus(null);
loadChats(); loadChats();
}
} catch (e: any) {
if (e.status === 404) {
console.warn('Import job not found');
} else {
console.error('Failed to poll status', e);
}
} }
} catch (e: any) {
if (e.status === 404) {
console.warn('Import job not found');
} else {
console.error('Failed to poll status', e);
}
}
}; };
poll(); poll();
@@ -187,12 +187,31 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
// Refs and logic for tracking session's first unread message to show the divider exactly once per load // Refs and logic for tracking session's first unread message to show the divider exactly once per load
const sessionUnreadRef = useRef<{ chatId: string, msgId: string | null }>({ chatId: '', msgId: null }); const sessionUnreadRef = useRef<{ chatId: string, msgId: string | null }>({ chatId: '', msgId: null });
if (activeChat && activeChat !== sessionUnreadRef.current.chatId && !isLoadingMessages) { // Update sessionUnreadRef when chat changes OR when messages are marked as read
const firstUnreadMsg = chatMessages.find( useEffect(() => {
(m) => m.senderId !== user?.id && !m.readBy?.some((r) => r.userId === user?.id) if (!activeChat || isLoadingMessages) return;
);
sessionUnreadRef.current = { chatId: activeChat, msgId: firstUnreadMsg ? firstUnreadMsg.id : null }; // Reset on chat change
} if (activeChat !== sessionUnreadRef.current.chatId) {
const firstUnreadMsg = chatMessages.find(
(m) => m.senderId !== user?.id && !m.readBy?.some((r) => r.userId === user?.id)
);
sessionUnreadRef.current = { chatId: activeChat, msgId: firstUnreadMsg ? firstUnreadMsg.id : null };
} else {
// Update if the first unread message was read (msgId no longer exists in unread list)
const firstUnreadMsg = chatMessages.find(
(m) => m.senderId !== user?.id && !m.readBy?.some((r) => r.userId === user?.id)
);
if (sessionUnreadRef.current.msgId && !firstUnreadMsg) {
// All messages are now read
sessionUnreadRef.current.msgId = null;
} else if (firstUnreadMsg && sessionUnreadRef.current.msgId !== firstUnreadMsg.id) {
// First unread changed (some messages were read)
sessionUnreadRef.current.msgId = firstUnreadMsg.id;
}
}
}, [activeChat, chatMessages, user?.id, isLoadingMessages]);
const firstUnreadId = activeChat === sessionUnreadRef.current.chatId ? sessionUnreadRef.current.msgId : null; const firstUnreadId = activeChat === sessionUnreadRef.current.chatId ? sessionUnreadRef.current.msgId : null;
const initialScrollChatId = useRef<string | null>(null); const initialScrollChatId = useRef<string | null>(null);
@@ -270,19 +289,19 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
const saveScrollPosition = useCallback((targetChatId?: string) => { const saveScrollPosition = useCallback((targetChatId?: string) => {
const container = scrollContainerRef.current; const container = scrollContainerRef.current;
const chatId = targetChatId || activeChat; const chatId = targetChatId || activeChat;
if (!container || !chatId || !scrollReady || isInitializingRef.current || isScrollingToBottomRef.current) return; if (!container || !chatId || !scrollReady || isInitializingRef.current || isScrollingToBottomRef.current) return;
// Проверка: сообщения в стейте должны быть от целевого чата // Проверка: сообщения в стейте должны быть от целевого чата
if (chatMessages.length > 0 && chatMessages[0].chatId !== chatId) return; if (chatMessages.length > 0 && chatMessages[0].chatId !== chatId) return;
const isAtBottomNow = container.scrollHeight - container.scrollTop - container.clientHeight < 40; const isAtBottomNow = container.scrollHeight - container.scrollTop - container.clientHeight < 40;
isAtBottomRef.current = isAtBottomNow; isAtBottomRef.current = isAtBottomNow;
if (isAtBottomNow) { if (isAtBottomNow) {
localStorage.setItem(`chat_at_bottom_${chatId}`, 'true'); localStorage.setItem(`chat_at_bottom_${chatId}`, 'true');
localStorage.removeItem(`chat_anchor_${chatId}`); localStorage.removeItem(`chat_anchor_${chatId}`);
return; return;
} }
const messageElements = container.querySelectorAll('[data-message-id]'); const messageElements = container.querySelectorAll('[data-message-id]');
@@ -304,8 +323,8 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
} }
if (anchor && anchor.id) { if (anchor && anchor.id) {
localStorage.setItem(`chat_anchor_${chatId}`, JSON.stringify(anchor)); localStorage.setItem(`chat_anchor_${chatId}`, JSON.stringify(anchor));
localStorage.removeItem(`chat_at_bottom_${chatId}`); localStorage.removeItem(`chat_at_bottom_${chatId}`);
} }
}, [activeChat, scrollReady, chatMessages]); }, [activeChat, scrollReady, chatMessages]);
@@ -313,7 +332,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
const restoreScrollPosition = useCallback(() => { const restoreScrollPosition = useCallback(() => {
const container = scrollContainerRef.current; const container = scrollContainerRef.current;
if (!container || !activeChat) return false; if (!container || !activeChat) return false;
if (chatMessages.length > 0 && chatMessages[0].chatId !== activeChat) return false; if (chatMessages.length > 0 && chatMessages[0].chatId !== activeChat) return false;
if (chatMessages.length === 0) return true; if (chatMessages.length === 0) return true;
@@ -330,16 +349,16 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
try { try {
const { id, offset } = JSON.parse(saved); const { id, offset } = JSON.parse(saved);
let el = container.querySelector(`[data-message-id="${id}"]`) as HTMLElement; let el = container.querySelector(`[data-message-id="${id}"]`) as HTMLElement;
// Поиск ближайшего, если точное сообщение еще не загружено // Поиск ближайшего, если точное сообщение еще не загружено
if (!el) { if (!el) {
const msgIndex = chatMessages.findIndex(m => m.id === id); const msgIndex = chatMessages.findIndex(m => m.id === id);
if (msgIndex !== -1) { if (msgIndex !== -1) {
for (let i = msgIndex; i < chatMessages.length; i++) { for (let i = msgIndex; i < chatMessages.length; i++) {
const nextEl = container.querySelector(`[data-message-id="${chatMessages[i].id}"]`) as HTMLElement; const nextEl = container.querySelector(`[data-message-id="${chatMessages[i].id}"]`) as HTMLElement;
if (nextEl) { el = nextEl; break; } if (nextEl) { el = nextEl; break; }
} }
} }
} }
if (el) { if (el) {
@@ -368,7 +387,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
useEffect(() => { useEffect(() => {
if (isLoadingMessages || !scrollContainerRef.current || !activeChat) return; if (isLoadingMessages || !scrollContainerRef.current || !activeChat) return;
const container = scrollContainerRef.current; const container = scrollContainerRef.current;
const observer = new ResizeObserver(() => { const observer = new ResizeObserver(() => {
if (isInitializingRef.current) return; if (isInitializingRef.current) return;
@@ -405,18 +424,18 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
// Сохраняем позицию старого чата // Сохраняем позицию старого чата
if (prevChatIdRef.current && scrollContainerRef.current && scrollReady) { if (prevChatIdRef.current && scrollContainerRef.current && scrollReady) {
saveScrollPosition(prevChatIdRef.current); saveScrollPosition(prevChatIdRef.current);
} }
setScrollReady(false); setScrollReady(false);
prevChatIdRef.current = activeChat; prevChatIdRef.current = activeChat;
if (scrollContainerRef.current) { if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = 0; scrollContainerRef.current.scrollTop = 0;
} }
const timer = setTimeout(() => { const timer = setTimeout(() => {
isInitializingRef.current = false; isInitializingRef.current = false;
}, 600); }, 600);
return () => clearTimeout(timer); return () => clearTimeout(timer);
@@ -443,7 +462,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
if (scrollTimeoutRef.current) clearTimeout(scrollTimeoutRef.current); if (scrollTimeoutRef.current) clearTimeout(scrollTimeoutRef.current);
scrollTimeoutRef.current = setTimeout(() => saveScrollPosition(), 150); scrollTimeoutRef.current = setTimeout(() => saveScrollPosition(), 150);
const st = container.scrollTop; const st = container.scrollTop;
const isScrollingUp = st < lastScrollTopRef.current; const isScrollingUp = st < lastScrollTopRef.current;
const stChanged = st !== lastScrollTopRef.current; const stChanged = st !== lastScrollTopRef.current;
@@ -473,7 +492,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
const containerRect = container.getBoundingClientRect(); const containerRect = container.getBoundingClientRect();
const messageElements = container.querySelectorAll('[data-message-id]'); const messageElements = container.querySelectorAll('[data-message-id]');
let currentTopMsgId = null; let currentTopMsgId = null;
for (const el of messageElements) { for (const el of messageElements) {
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
@@ -506,15 +525,15 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
// Принудительный сброс режима (Второй Клик) // Принудительный сброс режима (Второй Клик)
localStorage.setItem(`chat_at_bottom_${activeChat}`, 'true'); localStorage.setItem(`chat_at_bottom_${activeChat}`, 'true');
localStorage.removeItem(`chat_anchor_${activeChat}`); localStorage.removeItem(`chat_anchor_${activeChat}`);
isScrollingToBottomRef.current = true; isScrollingToBottomRef.current = true;
if (scrollContainerRef.current) { if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({ scrollContainerRef.current.scrollTo({
top: scrollContainerRef.current.scrollHeight, top: scrollContainerRef.current.scrollHeight,
behavior: 'smooth' behavior: 'smooth'
}); });
} }
setTimeout(() => { setTimeout(() => {
isScrollingToBottomRef.current = false; isScrollingToBottomRef.current = false;
}, 1000); }, 1000);
@@ -573,6 +592,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
{ {
root: scrollContainerRef.current, root: scrollContainerRef.current,
threshold: 0.1, threshold: 0.1,
rootMargin: '0px',
} }
); );
@@ -584,7 +604,26 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
unreadElements.forEach((el: Element) => { unreadElements.forEach((el: Element) => {
const id = el.getAttribute('data-message-id'); const id = el.getAttribute('data-message-id');
if (id && !sentReadIdsRef.current.has(id)) { if (id && !sentReadIdsRef.current.has(id)) {
observer.observe(el); // Check if element is already visible
const rect = el.getBoundingClientRect();
const containerRect = scrollContainerRef.current!.getBoundingClientRect();
const isVisible = rect.top >= containerRect.top && rect.bottom <= containerRect.bottom;
if (isVisible) {
// Mark as read immediately without waiting for intersection
const seqId = parseInt(el.getAttribute('data-sequence-id') || '0', 10);
if (seqId > 0) {
socket.emit('read_messages', {
chatId: activeChat,
lastReadMessageId: id,
lastReadSequenceId: seqId,
});
useChatStore.getState().markRead(activeChat, user.id, seqId);
sentReadIdsRef.current.add(id);
}
} else {
observer.observe(el);
}
} }
}); });
}; };
@@ -646,7 +685,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
<section className="flex-1 h-full flex flex-col items-center justify-center bg-[#010101] relative overflow-hidden"> <section className="flex-1 h-full flex flex-col items-center justify-center bg-[#010101] relative overflow-hidden">
{/* Background Knot Texture */} {/* Background Knot Texture */}
<div className="absolute inset-0 opacity-[0.04] pointer-events-none flex items-center justify-center"> <div className="absolute inset-0 opacity-[0.04] pointer-events-none flex items-center justify-center">
<span className="material-symbols-outlined text-white text-[500px] select-none">cloud_download</span> <span className="material-symbols-outlined text-white text-[500px] select-none">cloud_download</span>
</div> </div>
{/* Chat Empty State View */} {/* Chat Empty State View */}
@@ -1152,16 +1191,16 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between mb-2 px-1"> <div className="flex items-center justify-between mb-2 px-1">
<span className="text-xs font-black uppercase tracking-widest text-primary"> <span className="text-xs font-black uppercase tracking-widest text-primary">
{importStatus?.status === 'Processing' ? 'Обработка' : {importStatus?.status === 'Processing' ? 'Обработка' :
importStatus?.status === 'Queued' ? 'В очереди' : 'Загрузка'} importStatus?.status === 'Queued' ? 'В очереди' : 'Загрузка'}
</span> </span>
<span className="text-xs font-black text-on-surface tabular-nums"> <span className="text-xs font-black text-on-surface tabular-nums">
{importStatus?.processed || 0} / {importStatus?.total || 0} {importStatus?.processed || 0} / {importStatus?.total || 0}
</span> </span>
</div> </div>
<div className="h-3 w-full bg-surface-container-highest rounded-full overflow-hidden border border-outline/10 p-0.5"> <div className="h-3 w-full bg-surface-container-highest rounded-full overflow-hidden border border-outline/10 p-0.5">
<motion.div <motion.div
initial={{ width: 0 }} initial={{ width: 0 }}
animate={{ width: `${Math.min(100, Math.round(((importStatus?.processed || 0) / (importStatus?.total || 1)) * 100))}%` }} animate={{ width: `${Math.min(100, Math.round(((importStatus?.processed || 0) / (importStatus?.total || 1)) * 100))}%` }}
transition={{ type: 'spring', damping: 20 }} transition={{ type: 'spring', damping: 20 }}
@@ -1199,9 +1238,9 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
{chatPinnedMessages.length > 1 && ( {chatPinnedMessages.length > 1 && (
<div className="absolute left-1 top-1.5 bottom-1.5 w-0.5 rounded-full bg-white/5 flex flex-col gap-0.5 overflow-hidden"> <div className="absolute left-1 top-1.5 bottom-1.5 w-0.5 rounded-full bg-white/5 flex flex-col gap-0.5 overflow-hidden">
{chatPinnedMessages.map((_, idx) => ( {chatPinnedMessages.map((_, idx) => (
<div <div
key={idx} key={idx}
className={`flex-1 transition-colors duration-300 ${idx === pinnedIndex % chatPinnedMessages.length ? 'bg-primary' : 'bg-primary/20'}`} className={`flex-1 transition-colors duration-300 ${idx === pinnedIndex % chatPinnedMessages.length ? 'bg-primary' : 'bg-primary/20'}`}
/> />
))} ))}
</div> </div>
@@ -1226,8 +1265,8 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
{t('pinnedMessage')} {chatPinnedMessages.length > 1 ? `#${(pinnedIndex % chatPinnedMessages.length) + 1}` : ''} {t('pinnedMessage')} {chatPinnedMessages.length > 1 ? `#${(pinnedIndex % chatPinnedMessages.length) + 1}` : ''}
</p> </p>
<p className="text-sm text-zinc-300 truncate font-medium"> <p className="text-sm text-zinc-300 truncate font-medium">
{chatPinnedMessages[pinnedIndex % chatPinnedMessages.length]?.content || {chatPinnedMessages[pinnedIndex % chatPinnedMessages.length]?.content ||
(chatPinnedMessages[pinnedIndex % chatPinnedMessages.length]?.media?.length > 0 ? t('media') : '...')} (chatPinnedMessages[pinnedIndex % chatPinnedMessages.length]?.media?.length > 0 ? t('media') : '...')}
</p> </p>
</div> </div>
</button> </button>
@@ -1377,11 +1416,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
</> </>
)} )}
{typingInChat.length > 0 && ( {/* Typing indicator is already shown in the header, removed from here to prevent layout jumping */}
<div className="px-4 pb-1">
<TypingIndicator />
</div>
)}
{(() => { {(() => {
return ( return (
File diff suppressed because it is too large Load Diff
@@ -331,65 +331,70 @@ export default function MessageInput({ chatId }: MessageInputProps) {
}); });
}; };
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const processFiles = useCallback((files: File[]) => {
const files = Array.from(e.target.files || []); const { addNotification } = useNotificationStore.getState();
if (files.length > 0) { const newAttachments: Attachment[] = [];
const { addNotification } = useNotificationStore.getState();
const newAttachments: Attachment[] = []; let tooLarge = false;
let limitExceeded = false;
let tooLarge = false;
let limitExceeded = false;
for (const file of files) { for (const file of files) {
if (attachments.length + newAttachments.length >= 20) { if (attachments.length + newAttachments.length >= 20) {
limitExceeded = true; limitExceeded = true;
break; break;
} }
if (file.size > MAX_FILE_SIZE) { if (file.size > MAX_FILE_SIZE) {
tooLarge = true; tooLarge = true;
continue; continue;
}
const isAudio = file.type.startsWith('audio/') || AUDIO_EXTENSIONS.some(ext => file.name.toLowerCase().endsWith(ext));
newAttachments.push({ file, type: isAudio ? 'audio' : 'file' });
} }
if (tooLarge) addNotification('warning', t('fileTooLarge') || 'Some files are too large'); const isVideo = file.type.startsWith('video/');
if (limitExceeded) addNotification('warning', t('maxFilesLimit' as any) || 'Maximum 20 files'); const isImage = file.type.startsWith('image/');
const isAudio = file.type.startsWith('audio/') || AUDIO_EXTENSIONS.some(ext => file.name.toLowerCase().endsWith(ext));
setAttachments(prev => [...prev, ...newAttachments]);
inputRef.current?.focus(); const type = isImage ? 'image' : isVideo ? 'video' : isAudio ? 'audio' : 'file';
const preview = isImage ? URL.createObjectURL(file) : undefined;
newAttachments.push({ file, type, preview });
} }
if (tooLarge) addNotification('warning', t('fileTooLarge') || 'Some files are too large');
if (limitExceeded) addNotification('warning', t('maxFilesLimit' as any) || 'Maximum 20 files');
setAttachments(prev => [...prev, ...newAttachments]);
inputRef.current?.focus();
}, [attachments, t]);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (files.length > 0) processFiles(files);
e.target.value = ''; e.target.value = '';
setShowAttachMenu(false); setShowAttachMenu(false);
}; };
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []); const files = Array.from(e.target.files || []);
if (files.length > 0) { if (files.length > 0) processFiles(files);
const { addNotification } = useNotificationStore.getState();
const newAttachments: Attachment[] = [];
let limitExceeded = false;
for (const file of files) {
if (attachments.length + newAttachments.length >= 20) {
limitExceeded = true;
break;
}
const isVideo = file.type.startsWith('video/');
const preview = file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined;
newAttachments.push({ file, preview, type: isVideo ? 'video' : 'image' });
}
if (limitExceeded) addNotification('warning', t('maxFilesLimit' as any) || 'Maximum 20 files');
setAttachments(prev => [...prev, ...newAttachments]);
inputRef.current?.focus();
}
e.target.value = ''; e.target.value = '';
setShowAttachMenu(false); setShowAttachMenu(false);
}; };
const handlePaste = (e: React.ClipboardEvent) => {
const items = Array.from(e.clipboardData.items);
const files = items
.filter(item => item.kind === 'file')
.map(item => item.getAsFile())
.filter((f): f is File => f !== null);
if (files.length > 0) {
processFiles(files);
// If we only pasted files, don't paste the filename/text representation in the textarea
if (items.every(item => item.kind === 'file')) {
e.preventDefault();
}
}
};
// Запись голосового // Запись голосового
const startRecording = async () => { const startRecording = async () => {
try { try {
@@ -569,41 +574,11 @@ export default function MessageInput({ chatId }: MessageInputProps) {
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
const files = Array.from(e.dataTransfer.files); const files = Array.from(e.dataTransfer.files);
const { addNotification } = useNotificationStore.getState(); processFiles(files);
const newAttachments: Attachment[] = [];
let tooLarge = false;
let limitExceeded = false;
for (const file of files) {
if (attachments.length + newAttachments.length >= 20) {
limitExceeded = true;
break;
}
if (file.size > MAX_FILE_SIZE) {
tooLarge = true;
continue;
}
const isVideo = file.type.startsWith('video/');
const isImage = file.type.startsWith('image/');
const audioExts = ['.mp3', '.wav', '.ogg', '.m4a', '.aac', '.flac', '.wma', '.opus'];
const isAudio = file.type.startsWith('audio/') || audioExts.some(ext => file.name.toLowerCase().endsWith(ext));
const type = isImage ? 'image' : isVideo ? 'video' : isAudio ? 'audio' : 'file';
const preview = isImage ? URL.createObjectURL(file) : undefined;
newAttachments.push({ file, type, preview });
}
if (tooLarge) addNotification('warning', t('fileTooLarge') || 'Some files are too large');
if (limitExceeded) addNotification('warning', t('maxFilesLimit' as any) || 'Maximum 20 files');
setAttachments(prev => [...prev, ...newAttachments]);
inputRef.current?.focus();
} }
}; };
const hasContent = text.trim() || attachments.length > 0; const hasContent = text.trim() || attachments.length > 0;
return ( return (
@@ -856,6 +831,7 @@ export default function MessageInput({ chatId }: MessageInputProps) {
}} }}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onContextMenu={handleInputContextMenu} onContextMenu={handleInputContextMenu}
onPaste={handlePaste}
rows={1} rows={1}
className="w-full bg-transparent border-none focus:ring-0 text-[#efeff3] placeholder-on-surface-variant/30 text-[16px] leading-[1.3] resize-none max-h-[140px] custom-scrollbar outline-none py-1 px-0" className="w-full bg-transparent border-none focus:ring-0 text-[#efeff3] placeholder-on-surface-variant/30 text-[16px] leading-[1.3] resize-none max-h-[140px] custom-scrollbar outline-none py-1 px-0"
placeholder={attachments.length > 0 ? t('addCaption') : t('messagePlaceholder') || 'Сообщение...'} placeholder={attachments.length > 0 ? t('addCaption') : t('messagePlaceholder') || 'Сообщение...'}