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

@@ -6,6 +6,11 @@ const STATIC_ASSETS = [
'/favicon.svg' '/favicon.svg'
]; ];
// Активный чат, о котором сообщает клиент через postMessage.
// Используется для подавления push-уведомлений, когда пользователь уже
// смотрит в соответствующий чат.
let activeChat = null; // { type: 'messenger'|'task', id: number, visible: boolean, updatedAt: number }
// ── IndexedDB helpers (mirrors offlineQueue.ts schema, must stay in sync) ──── // ── IndexedDB helpers (mirrors offlineQueue.ts schema, must stay in sync) ────
// Schema history: // Schema history:
// v1 – `queue` store (keyPath: id, index: enqueuedAt) // v1 – `queue` store (keyPath: id, index: enqueuedAt)
@@ -480,6 +485,26 @@ async function sendToForegroundClients(payload) {
} }
} }
function shouldSuppressPushForActiveChat(payload) {
if (!activeChat || !activeChat.visible) return false;
// Игнорируем устаревшие записи.
const maxAgeMs = 60 * 1000;
if (!activeChat.updatedAt || Date.now() - activeChat.updatedAt > maxAgeMs) return false;
const nestedType = payload.data?.type;
if (nestedType === 'messenger' && activeChat.type === 'messenger') {
return parseInt(payload.data?.conversationId, 10) === activeChat.id;
}
if ((nestedType === 'chat' || payload.type === 'chat') && activeChat.type === 'task') {
return parseInt(payload.data?.taskId || payload.taskId, 10) === activeChat.id;
}
return false;
}
self.addEventListener('push', (event) => { self.addEventListener('push', (event) => {
console.log('[SW] Push notification received'); console.log('[SW] Push notification received');
@@ -509,6 +534,12 @@ self.addEventListener('push', (event) => {
return; return;
} }
// Подавляем push, если пользователь уже смотрит в этот чат на активной вкладке.
if (shouldSuppressPushForActiveChat(data)) {
console.log('[SW] Push suppressed for active chat:', activeChat);
return;
}
event.waitUntil( event.waitUntil(
hasVisibleClient().then((visible) => { hasVisibleClient().then((visible) => {
if (visible) { if (visible) {
@@ -620,5 +651,21 @@ self.addEventListener('pushsubscriptionchange', (event) => {
self.addEventListener('message', (event) => { self.addEventListener('message', (event) => {
if (event.data?.type === 'SKIP_WAITING') { if (event.data?.type === 'SKIP_WAITING') {
self.skipWaiting(); self.skipWaiting();
return;
}
if (event.data?.type === 'ACTIVE_CHAT') {
const payload = event.data?.payload || {};
activeChat = {
type: payload.type,
id: payload.id,
visible: !!payload.visible,
updatedAt: Date.now(),
};
return;
}
if (event.data?.type === 'CLEAR_ACTIVE_CHAT') {
activeChat = null;
} }
}); });

View File

@@ -24,7 +24,7 @@ import { Badge } from '@/components/ui/badge';
import { Card } from '@/components/ui/card'; 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 { 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 { 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 { PendingAttachmentList, AttachmentDisplay } from '@/components/chat/ChatAttachments';
import type { ChatAttachment } from '@/components/chat/ChatAttachments'; import type { ChatAttachment } from '@/components/chat/ChatAttachments';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
@@ -238,6 +238,7 @@ const TaskChat = ({ taskId }: TaskChatProps) => {
// Мгновенно добавляем новое сообщение если оно относится к текущей задаче // Мгновенно добавляем новое сообщение если оно относится к текущей задаче
// Проверяем author для сообщений пользователей, bot для сообщений ботов // Проверяем author для сообщений пользователей, bot для сообщений ботов
const isFromCurrentUser = data.message.author && user && data.message.author.id === user.id; 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) { if (data.taskId === taskId && !isFromCurrentUser) {
// Обновляем кэш с новым сообщением // Обновляем кэш с новым сообщением
@@ -256,6 +257,11 @@ const TaskChat = ({ taskId }: TaskChatProps) => {
messages: [...currentMessages, data.message] messages: [...currentMessages, data.message]
}; };
}); });
// Автоотметка прочтения для видимого открытого чата.
if (isVisible && data.message.id > 0) {
markAsReadMutation.mutate(data.message.id);
}
} }
}, },
onReactionUpdated: (data) => { onReactionUpdated: (data) => {
@@ -508,6 +514,29 @@ const TaskChat = ({ taskId }: TaskChatProps) => {
} }
}, [messages.length, isLoading, user, hasMarkedInitialMessages]); }, [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. // Keep activeQueueIdsRef in sync with IndexedDB whenever the queue changes.
// The offline-queue-updated event fires on enqueue (queryClient.ts) and on // The offline-queue-updated event fires on enqueue (queryClient.ts) and on
// successful item removal (useOfflineSync.ts), so this covers all transitions. // 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 { export function initPWA(): void {
window.addEventListener('beforeinstallprompt', (e) => { window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault(); e.preventDefault();

View File

@@ -10,6 +10,7 @@ import {
dispatchAttachmentQueueUpdated, dispatchAttachmentQueueUpdated,
} from '@/lib/attachmentQueue'; } from '@/lib/attachmentQueue';
import { getAll as getAllQueued } from '@/lib/offlineQueue'; import { getAll as getAllQueued } from '@/lib/offlineQueue';
import { reportActiveChat, clearActiveChat, setActiveChatInSw, clearActiveChatInSw } from '@/lib/pwa';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { useEvents } from '@/hooks/useEvents'; import { useEvents } from '@/hooks/useEvents';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
@@ -1048,6 +1049,29 @@ export default function ChatPage() {
}; };
}, [activeConvId]); }, [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 activeConv = conversations.find(c => c.id === activeConvId) ?? null;
const { data: msgsData, isLoading: msgsLoading } = useQuery<{ success: boolean; messages: ConvMessage[] }>({ const { data: msgsData, isLoading: msgsLoading } = useQuery<{ success: boolean; messages: ConvMessage[] }>({

View File

@@ -0,0 +1,8 @@
-- Добавляем информацию об активном чате пользователя для подавления push-уведомлений,
-- когда получатель уже смотрит в нужный чат на видимой вкладке.
ALTER TABLE user_presence
ADD COLUMN IF NOT EXISTS active_chat_type text,
ADD COLUMN IF NOT EXISTS active_chat_id integer,
ADD COLUMN IF NOT EXISTS active_chat_visible boolean DEFAULT false,
ADD COLUMN IF NOT EXISTS active_chat_updated_at timestamp DEFAULT now();

View File

@@ -109,6 +109,40 @@ export function registerPushRoutes(app: import("express").Express): void {
} }
}); });
// Update the chat currently open on the user's visible tab.
// Used by the server to suppress push notifications for active chats.
app.post('/api/devices/active-chat', authenticateToken, async (req: AuthenticatedRequest, res) => {
try {
const userId = req.user!.userId;
const organizationId = req.user!.organizationId;
const { type, id, visible } = req.body;
if (!['messenger', 'task'].includes(type) || typeof id !== 'number' || typeof visible !== 'boolean') {
return res.status(400).json({ error: 'Некорректные параметры активного чата' });
}
await storage.updateUserActiveChat(userId, organizationId, type, id, visible);
res.json({ success: true });
} catch (error) {
console.error('Active chat update error:', error);
res.status(500).json({ error: 'Ошибка обновления активного чата' });
}
});
// Clear active chat when the user leaves the chat page.
app.delete('/api/devices/active-chat', authenticateToken, async (req: AuthenticatedRequest, res) => {
try {
const userId = req.user!.userId;
const organizationId = req.user!.organizationId;
await storage.clearUserActiveChat(userId, organizationId);
res.json({ success: true });
} catch (error) {
console.error('Active chat clear error:', error);
res.status(500).json({ error: 'Ошибка сброса активного чата' });
}
});
// Get user's registered devices // Get user's registered devices
app.get('/api/devices', authenticateToken, async (req: AuthenticatedRequest, res) => { app.get('/api/devices', authenticateToken, async (req: AuthenticatedRequest, res) => {
try { try {

View File

@@ -13,6 +13,7 @@ import {
import { webPushService } from "./web-push.service"; import { webPushService } from "./web-push.service";
import { eq, and, or, isNull, inArray, sql } from "drizzle-orm"; import { eq, and, or, isNull, inArray, sql } from "drizzle-orm";
import { storage } from "../storage"; import { storage } from "../storage";
import { eventBus } from "../routes/shared";
// Интерфейс для push-уведомления (для отправки через Pushy) // Интерфейс для push-уведомления (для отправки через Pushy)
export interface PushNotificationPayload { export interface PushNotificationPayload {
@@ -582,6 +583,32 @@ export class NotificationService {
// === Push Notification Methods === // === Push Notification Methods ===
/**
* Проверяет, нужно ли подавить видимый push, потому что пользователь уже
* смотрит в соответствующий чат на активной видимой вкладке.
*/
private async shouldSkipPushForActiveChat(
userId: number,
organizationId: number,
chatType: 'messenger' | 'task',
chatId: number
): Promise<boolean> {
// Подавляем только если у пользователя есть активное SSE-соединение,
// т.е. открыто приложение.
if (!eventBus.isUserConnected(userId, organizationId)) return false;
const activeChat = await storage.getUserActiveChat(userId, organizationId);
if (!activeChat) return false;
if (!activeChat.visible || activeChat.type !== chatType || activeChat.id !== chatId) return false;
// Игнорируем устаревшие записи (например, вкладка закрылась, но не успела
// сообщить об этом).
const maxAgeMs = 60 * 1000;
if (!activeChat.updatedAt || Date.now() - activeChat.updatedAt.getTime() > maxAgeMs) return false;
return true;
}
/** /**
* Добавляет push-уведомление в очередь для офлайн-пользователей. * Добавляет push-уведомление в очередь для офлайн-пользователей.
* Вызывается после создания in-app уведомления. * Вызывается после создания in-app уведомления.
@@ -596,6 +623,18 @@ export class NotificationService {
actions?: Array<{ action: string; title: string }> actions?: Array<{ action: string; title: string }>
): Promise<void> { ): Promise<void> {
try { try {
// Если пользователь уже смотрит в task-чат — не присылаем видимый push.
if (data?.type === 'chat' && data?.taskId) {
const taskId = parseInt(data.taskId, 10);
if (!isNaN(taskId)) {
const shouldSkip = await this.shouldSkipPushForActiveChat(userId, organizationId, 'task', taskId);
if (shouldSkip) {
console.log(`[PushSuppress] Skipping push for user ${userId}: active task chat ${taskId}`);
return;
}
}
}
// Формируем правильный URL для открытия задачи // Формируем правильный URL для открытия задачи
let url = '/'; let url = '/';
if (data?.taskId && data?.formId) { if (data?.taskId && data?.formId) {
@@ -716,6 +755,13 @@ export class NotificationService {
// Push-уведомление: пропускаем для заглушённых чатов, // Push-уведомление: пропускаем для заглушённых чатов,
// КРОМЕ случая когда пользователь упомянут через @mention // КРОМЕ случая когда пользователь упомянут через @mention
if (channels.includes('push') && (!isMuted || isMentioned)) { if (channels.includes('push') && (!isMuted || isMentioned)) {
// Не шлём push, если пользователь уже смотрит в этот мессенджерный чат.
const shouldSkip = await this.shouldSkipPushForActiveChat(memberId, orgId, 'messenger', conversationId);
if (shouldSkip) {
console.log(`[PushSuppress] Skipping messenger push for user ${memberId}: active conv ${conversationId}`);
continue;
}
try { try {
await webPushService.sendToUser(memberId, orgId, { await webPushService.sendToUser(memberId, orgId, {
title: chatName, title: chatName,

View File

@@ -394,6 +394,88 @@ export class ContentStorage extends DataTablesStorage {
return userIds.filter(id => !onlineUserIds.has(id)); return userIds.filter(id => !onlineUserIds.has(id));
} }
async updateUserActiveChat(
userId: number,
organizationId: number,
type: 'messenger' | 'task',
id: number,
visible: boolean
): Promise<void> {
const existing = await db.select().from(userPresence)
.where(and(
eq(userPresence.userId, userId),
eq(userPresence.organizationId, organizationId)
))
.limit(1);
if (existing.length > 0) {
await db.update(userPresence)
.set({
activeChatType: type,
activeChatId: id,
activeChatVisible: visible,
activeChatUpdatedAt: new Date(),
lastSeenAt: new Date(),
})
.where(and(
eq(userPresence.userId, userId),
eq(userPresence.organizationId, organizationId)
));
} else {
await db.insert(userPresence).values({
userId,
organizationId,
isOnline: true,
lastSeenAt: new Date(),
activeDevices: 0,
activeChatType: type,
activeChatId: id,
activeChatVisible: visible,
activeChatUpdatedAt: new Date(),
});
}
}
async clearUserActiveChat(userId: number, organizationId: number): Promise<void> {
await db.update(userPresence)
.set({
activeChatType: null,
activeChatId: null,
activeChatVisible: false,
activeChatUpdatedAt: new Date(),
})
.where(and(
eq(userPresence.userId, userId),
eq(userPresence.organizationId, organizationId)
));
}
async getUserActiveChat(userId: number, organizationId: number): Promise<{
type: 'messenger' | 'task' | null;
id: number | null;
visible: boolean;
updatedAt: Date | null;
} | null> {
const [presence] = await db.select({
activeChatType: userPresence.activeChatType,
activeChatId: userPresence.activeChatId,
activeChatVisible: userPresence.activeChatVisible,
activeChatUpdatedAt: userPresence.activeChatUpdatedAt,
}).from(userPresence)
.where(and(
eq(userPresence.userId, userId),
eq(userPresence.organizationId, organizationId)
));
if (!presence) return null;
return {
type: (presence.activeChatType as 'messenger' | 'task') || null,
id: presence.activeChatId ?? null,
visible: presence.activeChatVisible ?? false,
updatedAt: presence.activeChatUpdatedAt ?? null,
};
}
// Web Push Subscriptions // Web Push Subscriptions
async registerWebPushSubscription(userId: number, organizationId: number, endpoint: string, subscription: string): Promise<void> { async registerWebPushSubscription(userId: number, organizationId: number, endpoint: string, subscription: string): Promise<void> {
await db.delete(webPushSubscriptions).where(eq(webPushSubscriptions.endpoint, endpoint)); await db.delete(webPushSubscriptions).where(eq(webPushSubscriptions.endpoint, endpoint));

View File

@@ -2493,6 +2493,12 @@ export const userPresence = pgTable("user_presence", {
isOnline: boolean("is_online").default(false), isOnline: boolean("is_online").default(false),
lastSeenAt: timestamp("last_seen_at").defaultNow(), lastSeenAt: timestamp("last_seen_at").defaultNow(),
activeDevices: integer("active_devices").default(0), activeDevices: integer("active_devices").default(0),
// Активный чат: используется сервером, чтобы не слать push, если пользователь
// уже смотрит в этот чат на видимой вкладке.
activeChatType: text("active_chat_type"), // 'messenger' | 'task'
activeChatId: integer("active_chat_id"),
activeChatVisible: boolean("active_chat_visible").default(false),
activeChatUpdatedAt: timestamp("active_chat_updated_at").defaultNow(),
}, (table) => ({ }, (table) => ({
userIndex: index("user_presence_user_idx").on(table.userId), userIndex: index("user_presence_user_idx").on(table.userId),
onlineIndex: index("user_presence_online_idx").on(table.isOnline), onlineIndex: index("user_presence_online_idx").on(table.isOnline),