Исправлен механизм прочтения сообщений
This commit is contained in:
@@ -125,9 +125,9 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
set({ isLoadingMessages: true });
|
||||
const currentMessages = state.messages[chatId] || [];
|
||||
const cursor = !reset && currentMessages.length > 0 ? currentMessages[0].sequenceId.toString() : undefined;
|
||||
|
||||
|
||||
const fetched = await ChatApi.getMessages(chatId, cursor);
|
||||
|
||||
|
||||
set((state) => {
|
||||
// Merge fetched messages with any that arrived via socket
|
||||
const existing = reset ? [] : (state.messages[chatId] || []);
|
||||
@@ -401,6 +401,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
if (m.sequenceId <= lastReadSequenceId) {
|
||||
const alreadyRead = m.readBy?.some((r) => r.userId === userId);
|
||||
if (alreadyRead) return m;
|
||||
// Увеличиваем счётчик только если текущий пользователь читает чужие сообщения
|
||||
if (userId === currentUserId && m.senderId !== currentUserId) newlyReadCount++;
|
||||
return { ...m, readBy: [...(m.readBy || []), { userId }] };
|
||||
}
|
||||
@@ -412,6 +413,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
const updatedChats = state.chats.map((chat) => {
|
||||
if (chat.id === chatId) {
|
||||
const updatedLastMessages = chat.messages?.map(updateMsg);
|
||||
// Уменьшаем unreadCount только если текущий пользователь прочитал сообщения
|
||||
if (userId === currentUserId) {
|
||||
return { ...chat, messages: updatedLastMessages, unreadCount: Math.max(0, (chat.unreadCount || 0) - newlyReadCount) };
|
||||
}
|
||||
@@ -475,16 +477,16 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
addChat: (chat) => {
|
||||
set((state) => {
|
||||
const existing = state.chats.find((c) => c.id === chat.id);
|
||||
|
||||
|
||||
const messagesFromState = state.messages[chat.id] || [];
|
||||
const messagesToUse = messagesFromState.length > 0 ? messagesFromState : (chat.messages || []);
|
||||
|
||||
|
||||
let unreadCount = chat.unreadCount || 0;
|
||||
if (!existing && messagesFromState.length > 0) {
|
||||
const userId = useAuthStore.getState().user?.id;
|
||||
unreadCount = messagesFromState.filter((m) => m.senderId !== userId && !m.readBy?.some(r => r.userId === userId)).length;
|
||||
}
|
||||
|
||||
|
||||
const updatedChat = { ...chat, messages: messagesToUse.length > 0 ? [messagesToUse[messagesToUse.length - 1]] : [], unreadCount };
|
||||
|
||||
if (existing) {
|
||||
@@ -524,9 +526,9 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
const existing = state.pinnedMessages[chatId] || [];
|
||||
if (existing.some(m => m.id === message.id)) return state;
|
||||
return {
|
||||
pinnedMessages: {
|
||||
...state.pinnedMessages,
|
||||
[chatId]: [...existing, message]
|
||||
pinnedMessages: {
|
||||
...state.pinnedMessages,
|
||||
[chatId]: [...existing, message]
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -550,7 +552,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
try {
|
||||
set({ isLoadingMessages: true });
|
||||
const fetched = await ChatApi.getMessages(chatId, undefined, sequenceId, 50);
|
||||
|
||||
|
||||
set((state) => ({
|
||||
messages: { ...state.messages, [chatId]: fetched },
|
||||
// Since we jumped, we assume there is more history to load above
|
||||
|
||||
@@ -58,7 +58,7 @@ export default function ChatPage() {
|
||||
const [groupCallSessionId, setGroupCallSessionId] = useState(0);
|
||||
|
||||
const [incomingGroupCall, setIncomingGroupCall] = useState<{ chatId: string; from: string; callerInfo: any; callType: string; chatName: string } | null>(null);
|
||||
|
||||
|
||||
const groupCallOpenRef = useRef(false);
|
||||
const groupCallChatIdRef = useRef('');
|
||||
|
||||
@@ -180,7 +180,12 @@ export default function ChatPage() {
|
||||
});
|
||||
|
||||
socket.on('messages_read', (data: any) => {
|
||||
markRead(data.chatId || data.ChatId, data.userId || data.UserId, data.lastReadSequenceId || data.LastReadSequenceId || 0);
|
||||
const chatId = data.chatId || data.ChatId;
|
||||
const userId = data.userId || data.UserId;
|
||||
const lastReadSequenceId = data.lastReadSequenceId || data.LastReadSequenceId || 0;
|
||||
|
||||
// Обновляем стейт - добавляем userId в readBy для всех сообщений до lastReadSequenceId
|
||||
markRead(chatId, userId, lastReadSequenceId);
|
||||
});
|
||||
|
||||
socket.on('user_typing', (data: { chatId: string; userId: string }) => {
|
||||
@@ -281,7 +286,7 @@ export default function ChatPage() {
|
||||
callType: data.callType,
|
||||
chatName: chat.name || 'Group',
|
||||
});
|
||||
|
||||
|
||||
// Auto-dismiss after 15 seconds if ignored
|
||||
setTimeout(() => {
|
||||
setIncomingGroupCall(prev => {
|
||||
@@ -383,14 +388,14 @@ export default function ChatPage() {
|
||||
{activeTab === 'chats' ? (
|
||||
<>
|
||||
{/* Chat List (Sidebar) */}
|
||||
<div
|
||||
<div
|
||||
className={`${activeChat ? 'hidden lg:block' : 'block'} w-full lg:w-[360px] flex-shrink-0 bg-surface-container-low h-full overflow-hidden`}
|
||||
>
|
||||
<Sidebar />
|
||||
</div>
|
||||
|
||||
{/* Selected Chat View (Main Area) */}
|
||||
<div
|
||||
<div
|
||||
className={`${activeChat ? 'block' : 'hidden lg:block'} flex-1 h-full min-w-0 bg-surface-container-lowest relative group slide-on-ice`}
|
||||
>
|
||||
<ChatView onStartCall={handleStartCall} onStartGroupCall={handleStartGroupCall} />
|
||||
@@ -398,31 +403,31 @@ export default function ChatPage() {
|
||||
</>
|
||||
) : activeTab === 'contacts' ? (
|
||||
<div className="flex-1 flex flex-row h-full overflow-hidden">
|
||||
{/* Contacts Sidebar List */}
|
||||
<div className="w-full lg:w-[360px] flex-shrink-0 bg-surface-container-low h-full overflow-hidden antialiased">
|
||||
<ContactsSidebar onSwitchToChat={() => setActiveTab('chats')} />
|
||||
</div>
|
||||
{/* Contacts Sidebar List */}
|
||||
<div className="w-full lg:w-[360px] flex-shrink-0 bg-surface-container-low h-full overflow-hidden antialiased">
|
||||
<ContactsSidebar onSwitchToChat={() => setActiveTab('chats')} />
|
||||
</div>
|
||||
|
||||
{/* Right side placeholder / Profile detail */}
|
||||
<div className="hidden lg:flex flex-1 items-center justify-center bg-surface-base h-full relative slide-on-ice">
|
||||
<div className="flex flex-col items-center gap-6 max-w-sm text-center">
|
||||
<div className="w-24 h-24 rounded-3xl bg-primary/10 flex items-center justify-center text-primary shadow-inner">
|
||||
<Users size={48} className="knot-logo-spin opacity-50" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white mb-2">{t('contacts')}</h2>
|
||||
<p className="text-sm text-zinc-500 leading-relaxed max-w-[280px]">
|
||||
{t('selectContactToChat')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setActiveTab('chats')}
|
||||
className="px-8 py-3 rounded-2xl bg-primary text-on-primary shadow-lg shadow-primary/20 hover:scale-105 active:scale-95 transition-all text-sm font-bold tracking-tight"
|
||||
>
|
||||
{t('backToChats')}
|
||||
</button>
|
||||
{/* Right side placeholder / Profile detail */}
|
||||
<div className="hidden lg:flex flex-1 items-center justify-center bg-surface-base h-full relative slide-on-ice">
|
||||
<div className="flex flex-col items-center gap-6 max-w-sm text-center">
|
||||
<div className="w-24 h-24 rounded-3xl bg-primary/10 flex items-center justify-center text-primary shadow-inner">
|
||||
<Users size={48} className="knot-logo-spin opacity-50" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white mb-2">{t('contacts')}</h2>
|
||||
<p className="text-sm text-zinc-500 leading-relaxed max-w-[280px]">
|
||||
{t('selectContactToChat')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setActiveTab('chats')}
|
||||
className="px-8 py-3 rounded-2xl bg-primary text-on-primary shadow-lg shadow-primary/20 hover:scale-105 active:scale-95 transition-all text-sm font-bold tracking-tight"
|
||||
>
|
||||
{t('backToChats')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : activeTab === 'settings' ? (
|
||||
<SettingsPage />
|
||||
@@ -432,7 +437,7 @@ export default function ChatPage() {
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
|
||||
|
||||
<CallModal
|
||||
key={callSessionId}
|
||||
@@ -485,9 +490,9 @@ export default function ChatPage() {
|
||||
>
|
||||
<div className="relative mb-6">
|
||||
<div className="absolute inset-0 rounded-[1.5rem] bg-emerald-500/20 animate-call-wave" />
|
||||
<Avatar
|
||||
src={incomingGroupCall.callerInfo?.avatar ? getMediaUrl(incomingGroupCall.callerInfo.avatar) : null}
|
||||
name={incomingGroupCall.chatName || '?'}
|
||||
<Avatar
|
||||
src={incomingGroupCall.callerInfo?.avatar ? getMediaUrl(incomingGroupCall.callerInfo.avatar) : null}
|
||||
name={incomingGroupCall.chatName || '?'}
|
||||
size="2xl"
|
||||
className="relative shadow-2xl"
|
||||
/>
|
||||
@@ -499,9 +504,9 @@ export default function ChatPage() {
|
||||
{incomingGroupCall.callerInfo?.displayName || incomingGroupCall.callerInfo?.username || 'User'} {t('isCalling' as any) || 'звонит...'}
|
||||
</p>
|
||||
<p className="text-zinc-400 text-sm mb-8 bg-white/5 px-3 py-1 rounded-full border border-white/5">
|
||||
{t('groupCall' as any) || 'Групповой звонок'}
|
||||
{t('groupCall' as any) || 'Групповой звонок'}
|
||||
</p>
|
||||
|
||||
|
||||
<div className="flex items-center gap-8 w-full justify-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
|
||||
@@ -63,14 +63,14 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
||||
: lastMessage.media?.[0]?.type === 'video'
|
||||
? t('video')
|
||||
: t('file')
|
||||
: lastMessage.type === 'call'
|
||||
? `${lastMessage.callType === 'video' ? '🎬' : '📞'} ${t(
|
||||
: lastMessage.type === 'call'
|
||||
? `${lastMessage.callType === 'video' ? '🎬' : '📞'} ${t(
|
||||
lastMessage.callStatus === 'missed' ? 'missedCall' :
|
||||
lastMessage.callStatus === 'declined' ? 'declinedCall' :
|
||||
lastMessage.callStatus === 'cancelled' ? 'cancelledCall' :
|
||||
'completedCall'
|
||||
lastMessage.callStatus === 'declined' ? 'declinedCall' :
|
||||
lastMessage.callStatus === 'cancelled' ? 'cancelledCall' :
|
||||
'completedCall'
|
||||
)}`
|
||||
: lastMessage.content || ''
|
||||
: lastMessage.content || ''
|
||||
: '';
|
||||
|
||||
const previewText = chat.isImporting ? 'Импорт...' : stripMarkdown(lastMessageText);
|
||||
@@ -78,7 +78,9 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
||||
const isMine = !chat.isImporting && lastMessage?.senderId === user?.id;
|
||||
|
||||
// Галочки прочтения
|
||||
const isRead = !chat.isImporting && lastMessage?.readBy?.some((r) => r.userId !== user?.id);
|
||||
// Для своих сообщений: проверено, есть ли в readBy другие пользователи (получатели)
|
||||
// Для чужих сообщений: не показываем галочки
|
||||
const isRead = !chat.isImporting && isMine && lastMessage?.readBy?.some((r) => r.userId !== user?.id);
|
||||
|
||||
const timeStr = !chat.isImporting && lastMessage
|
||||
? formatDistanceToNow(new Date(lastMessage.createdAt), { addSuffix: false, locale: lang === 'ru' ? ru : enUS })
|
||||
@@ -89,25 +91,25 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!chat.isImporting || !chat.importJobId) {
|
||||
setImportStatus(null);
|
||||
return;
|
||||
setImportStatus(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const data = await httpClient.request<any>(`/import/telegram/status/${chat.importJobId}`);
|
||||
setImportStatus({ processed: data.processedMessages, total: data.totalMessages });
|
||||
if (data.status === 'Completed' || data.status === 'Failed') {
|
||||
loadChats();
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.status === 404) {
|
||||
// Job might have expired or backend restarted
|
||||
console.warn('Import job not found');
|
||||
} else {
|
||||
console.error('Failed to poll status', e);
|
||||
}
|
||||
try {
|
||||
const data = await httpClient.request<any>(`/import/telegram/status/${chat.importJobId}`);
|
||||
setImportStatus({ processed: data.processedMessages, total: data.totalMessages });
|
||||
if (data.status === 'Completed' || data.status === 'Failed') {
|
||||
loadChats();
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.status === 404) {
|
||||
// Job might have expired or backend restarted
|
||||
console.warn('Import job not found');
|
||||
} else {
|
||||
console.error('Failed to poll status', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
@@ -117,9 +119,9 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
||||
|
||||
const handleClick = () => {
|
||||
if (chat.isImporting) {
|
||||
// Here we could show a progress modal, but for now just select it
|
||||
setActiveChat(chat.id);
|
||||
return;
|
||||
// Here we could show a progress modal, but for now just select it
|
||||
setActiveChat(chat.id);
|
||||
return;
|
||||
}
|
||||
if ((window as any).hasUnsavedAttachments && !isActive) {
|
||||
setShowAttachmentConfirm(true);
|
||||
@@ -131,7 +133,7 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
||||
const proceedWithClick = () => {
|
||||
setShowAttachmentConfirm(false);
|
||||
(window as any).hasUnsavedAttachments = false;
|
||||
|
||||
|
||||
if (isActive) {
|
||||
window.dispatchEvent(new CustomEvent('CHAT_SCROLL_TO_BOTTOM', { detail: { chatId: chat.id } }));
|
||||
} else {
|
||||
@@ -188,9 +190,8 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
||||
<button
|
||||
onClick={handleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
className={`w-full flex items-center gap-4 px-4 py-3.5 transition-all duration-300 slide-on-ice text-left rounded-2xl mx-1 my-0.5 w-[calc(100%-8px)] ${
|
||||
isActive ? 'bg-primary/10' : 'hover:bg-surface-container-highest/20'
|
||||
}`}
|
||||
className={`w-full flex items-center gap-4 px-4 py-3.5 transition-all duration-300 slide-on-ice text-left rounded-2xl mx-1 my-0.5 w-[calc(100%-8px)] ${isActive ? 'bg-primary/10' : 'hover:bg-surface-container-highest/20'
|
||||
}`}
|
||||
>
|
||||
{/* Аватар */}
|
||||
<div className="relative flex-shrink-0">
|
||||
@@ -200,7 +201,7 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="avatar-knot-container group-hover:scale-105 transition-transform">
|
||||
<Avatar src={chatAvatar || undefined} name={chatName || '??'} size="lg" online={isOnline ? true : false} />
|
||||
<Avatar src={chatAvatar || undefined} name={chatName || '??'} size="lg" online={isOnline ? true : false} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -225,20 +226,20 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
||||
)}
|
||||
<div className="flex flex-col gap-1 w-full min-w-0">
|
||||
<p className={`text-[13px] truncate leading-tight ${isTyping ? 'text-tertiary font-bold' : draft ? 'text-error font-medium' : 'text-on-surface-variant/60'}`}>
|
||||
{isTyping ? t('typing') : draft ? <><span className="font-bold">{t('draft')} </span>{stripMarkdown(draft)}</> : previewText}
|
||||
{chat.isImporting && importStatus && importStatus.total > 0 && (
|
||||
<span className="ml-1.5 text-[11px] font-black text-primary/70 tabular-nums">
|
||||
{importStatus.processed} / {importStatus.total}
|
||||
</span>
|
||||
)}
|
||||
{isTyping ? t('typing') : draft ? <><span className="font-bold">{t('draft')} </span>{stripMarkdown(draft)}</> : previewText}
|
||||
{chat.isImporting && importStatus && importStatus.total > 0 && (
|
||||
<span className="ml-1.5 text-[11px] font-black text-primary/70 tabular-nums">
|
||||
{importStatus.processed} / {importStatus.total}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{chat.isImporting && importStatus && importStatus.total > 0 && (
|
||||
<div className="w-full h-1 bg-surface-container-highest rounded-full overflow-hidden mt-0.5">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-500 ease-out"
|
||||
style={{ width: `${Math.min(100, Math.round((importStatus.processed / importStatus.total) * 100))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full h-1 bg-surface-container-highest rounded-full overflow-hidden mt-0.5">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-500 ease-out"
|
||||
style={{ width: `${Math.min(100, Math.round((importStatus.processed / importStatus.total) * 100))}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -79,7 +79,11 @@ function MessageBubble({
|
||||
const [quotedText, setQuotedText] = useState<string | null>(null);
|
||||
|
||||
// Прочитано
|
||||
const isRead = message.readBy?.some((r) => r.userId !== user?.id);
|
||||
// Для своих сообщений: проверено, есть ли в readBy другие пользователи (получатели)
|
||||
// Для чужих сообщений: проверено, есть ли в readBy текущий пользователь
|
||||
const isRead = isMine
|
||||
? message.readBy?.some((r) => r.userId !== user?.id) // Кто-то кроме меня прочитал
|
||||
: message.readBy?.some((r) => r.userId === user?.id); // Я прочитал
|
||||
|
||||
const timeStr = new Date(message.createdAt).toLocaleTimeString(lang === 'ru' ? 'ru-RU' : 'en-US', {
|
||||
hour: '2-digit',
|
||||
@@ -492,10 +496,10 @@ function MessageBubble({
|
||||
onDoubleClick={handleReply}
|
||||
title={t('reply') ? `${t('reply')} (Double Click)` : 'Double click to reply'}
|
||||
className={`cursor-pointer max-w-full min-w-[60px] transition-all duration-500 overflow-hidden ${!needsFrame
|
||||
? 'p-0 shadow-none border-none bg-transparent'
|
||||
: isMine
|
||||
? 'bubble-sent px-4 py-3 hover:brightness-110'
|
||||
: 'bubble-received px-4 py-3 hover:brightness-110'
|
||||
? 'p-0 shadow-none border-none bg-transparent'
|
||||
: isMine
|
||||
? 'bubble-sent px-4 py-3 hover:brightness-110'
|
||||
: 'bubble-received px-4 py-3 hover:brightness-110'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -1068,8 +1072,8 @@ function MessageBubble({
|
||||
key={emoji}
|
||||
onClick={(e) => { e.stopPropagation(); handleReaction(emoji); }}
|
||||
className={`flex items-center gap-2 px-2.5 py-1.5 rounded-[10px] transition-all border ${data.isMine
|
||||
? 'bg-primary/20 border-primary text-white shadow-lg'
|
||||
: 'bg-[#201F1F] border-white/5 text-zinc-300 hover:bg-[#2a2a2a]'
|
||||
? 'bg-primary/20 border-primary text-white shadow-lg'
|
||||
: 'bg-[#201F1F] border-white/5 text-zinc-300 hover:bg-[#2a2a2a]'
|
||||
} shadow-md group/react`}
|
||||
title={data.users.join(', ')}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user