fix(chat): подавлять push-уведомления при открытом активном чате и автоотметка прочтения в task chat

- Добавлены active_chat_* поля в user_presence и endpoint /api/devices/active-chat

- Сервер не шлёт Web Push/Pushy, если получатель смотрит в тот же чат на видимой вкладке

- Клиент сообщает серверу и Service Worker об активном чате из Chat.tsx и TaskChat.tsx

- TaskChat: входящие сообщения автоматически отмечаются прочитанными при видимой вкладке

- SW: подавляет foreground/system push, если активный чат совпадает
This commit is contained in:
2026-07-16 18:59:00 +03:00
parent 63c83a059d
commit 7672dd98df
9 changed files with 317 additions and 1 deletions

View File

@@ -24,7 +24,7 @@ import { Badge } from '@/components/ui/badge';
import { Card } from '@/components/ui/card';
import { Send, User, AtSign, Reply, X, Edit, Check, CheckCheck, MoreVertical, Bot, Link as LinkIcon, Clock, BarChart2, Paperclip, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { isPWAInstalled } from '@/lib/pwa';
import { isPWAInstalled, reportActiveChat, clearActiveChat, setActiveChatInSw, clearActiveChatInSw } from '@/lib/pwa';
import { PendingAttachmentList, AttachmentDisplay } from '@/components/chat/ChatAttachments';
import type { ChatAttachment } from '@/components/chat/ChatAttachments';
import ReactMarkdown from 'react-markdown';
@@ -238,6 +238,7 @@ const TaskChat = ({ taskId }: TaskChatProps) => {
// Мгновенно добавляем новое сообщение если оно относится к текущей задаче
// Проверяем author для сообщений пользователей, bot для сообщений ботов
const isFromCurrentUser = data.message.author && user && data.message.author.id === user.id;
const isVisible = typeof document !== 'undefined' && document.visibilityState === 'visible';
if (data.taskId === taskId && !isFromCurrentUser) {
// Обновляем кэш с новым сообщением
@@ -256,6 +257,11 @@ const TaskChat = ({ taskId }: TaskChatProps) => {
messages: [...currentMessages, data.message]
};
});
// Автоотметка прочтения для видимого открытого чата.
if (isVisible && data.message.id > 0) {
markAsReadMutation.mutate(data.message.id);
}
}
},
onReactionUpdated: (data) => {
@@ -508,6 +514,29 @@ const TaskChat = ({ taskId }: TaskChatProps) => {
}
}, [messages.length, isLoading, user, hasMarkedInitialMessages]);
// Сообщаем серверу и SW, что этот task-чат открыт, чтобы подавить push.
useEffect(() => {
if (!taskId || taskId <= 0) return;
const report = (visible: boolean) => {
reportActiveChat('task', taskId, visible);
setActiveChatInSw('task', taskId, visible);
};
report(document.visibilityState === 'visible');
const handleVisibility = () => {
report(document.visibilityState === 'visible');
};
document.addEventListener('visibilitychange', handleVisibility);
return () => {
document.removeEventListener('visibilitychange', handleVisibility);
clearActiveChat();
clearActiveChatInSw();
};
}, [taskId]);
// Keep activeQueueIdsRef in sync with IndexedDB whenever the queue changes.
// The offline-queue-updated event fires on enqueue (queryClient.ts) and on
// successful item removal (useOfflineSync.ts), so this covers all transitions.

View File

@@ -256,6 +256,46 @@ export async function initWebPush(): Promise<void> {
}
}
// ── Active chat reporting (used to suppress push for open chats) ──────────────
export async function reportActiveChat(
type: 'messenger' | 'task',
id: number,
visible: boolean
): Promise<void> {
try {
await apiRequest('POST', '/api/devices/active-chat', { type, id, visible });
} catch (error) {
console.error('[ActiveChat] Failed to report active chat:', error);
}
}
export async function clearActiveChat(): Promise<void> {
try {
await apiRequest('DELETE', '/api/devices/active-chat');
} catch (error) {
console.error('[ActiveChat] Failed to clear active chat:', error);
}
}
export function setActiveChatInSw(
type: 'messenger' | 'task',
id: number,
visible: boolean
): void {
if (!('serviceWorker' in navigator)) return;
const controller = navigator.serviceWorker.controller;
if (!controller) return;
controller.postMessage({ type: 'ACTIVE_CHAT', payload: { type, id, visible } });
}
export function clearActiveChatInSw(): void {
if (!('serviceWorker' in navigator)) return;
const controller = navigator.serviceWorker.controller;
if (!controller) return;
controller.postMessage({ type: 'CLEAR_ACTIVE_CHAT' });
}
export function initPWA(): void {
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();

View File

@@ -10,6 +10,7 @@ import {
dispatchAttachmentQueueUpdated,
} from '@/lib/attachmentQueue';
import { getAll as getAllQueued } from '@/lib/offlineQueue';
import { reportActiveChat, clearActiveChat, setActiveChatInSw, clearActiveChatInSw } from '@/lib/pwa';
import { useAuth } from '@/hooks/useAuth';
import { useEvents } from '@/hooks/useEvents';
import { useToast } from '@/hooks/use-toast';
@@ -1048,6 +1049,29 @@ export default function ChatPage() {
};
}, [activeConvId]);
// Report active chat to server and SW so push is suppressed while this chat is open.
useEffect(() => {
if (!activeConvId) return;
const report = (visible: boolean) => {
reportActiveChat('messenger', activeConvId, visible);
setActiveChatInSw('messenger', activeConvId, visible);
};
report(document.visibilityState === 'visible');
const handleVisibility = () => {
report(document.visibilityState === 'visible');
};
document.addEventListener('visibilitychange', handleVisibility);
return () => {
document.removeEventListener('visibilitychange', handleVisibility);
clearActiveChat();
clearActiveChatInSw();
};
}, [activeConvId]);
const activeConv = conversations.find(c => c.id === activeConvId) ?? null;
const { data: msgsData, isLoading: msgsLoading } = useQuery<{ success: boolean; messages: ConvMessage[] }>({