diff --git a/client/public/sw.js b/client/public/sw.js index dc445e0..10e1d8e 100644 --- a/client/public/sw.js +++ b/client/public/sw.js @@ -6,6 +6,11 @@ const STATIC_ASSETS = [ '/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) ──── // Schema history: // 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) => { console.log('[SW] Push notification received'); @@ -509,6 +534,12 @@ self.addEventListener('push', (event) => { return; } + // Подавляем push, если пользователь уже смотрит в этот чат на активной вкладке. + if (shouldSuppressPushForActiveChat(data)) { + console.log('[SW] Push suppressed for active chat:', activeChat); + return; + } + event.waitUntil( hasVisibleClient().then((visible) => { if (visible) { @@ -620,5 +651,21 @@ self.addEventListener('pushsubscriptionchange', (event) => { self.addEventListener('message', (event) => { if (event.data?.type === 'SKIP_WAITING') { 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; } }); diff --git a/client/src/components/TaskChat.tsx b/client/src/components/TaskChat.tsx index c5c345c..2340253 100644 --- a/client/src/components/TaskChat.tsx +++ b/client/src/components/TaskChat.tsx @@ -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. diff --git a/client/src/lib/pwa.ts b/client/src/lib/pwa.ts index 595a412..f4bbfdb 100644 --- a/client/src/lib/pwa.ts +++ b/client/src/lib/pwa.ts @@ -256,6 +256,46 @@ export async function initWebPush(): Promise { } } +// ── Active chat reporting (used to suppress push for open chats) ────────────── + +export async function reportActiveChat( + type: 'messenger' | 'task', + id: number, + visible: boolean +): Promise { + 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 { + 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(); diff --git a/client/src/pages/Chat.tsx b/client/src/pages/Chat.tsx index 1a00915..76757cc 100644 --- a/client/src/pages/Chat.tsx +++ b/client/src/pages/Chat.tsx @@ -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[] }>({ diff --git a/migrations/0061_add_user_active_chat_to_user_presence.sql b/migrations/0061_add_user_active_chat_to_user_presence.sql new file mode 100644 index 0000000..83b7ca6 --- /dev/null +++ b/migrations/0061_add_user_active_chat_to_user_presence.sql @@ -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(); diff --git a/server/routes/push.routes.ts b/server/routes/push.routes.ts index 831cea2..6d77740 100644 --- a/server/routes/push.routes.ts +++ b/server/routes/push.routes.ts @@ -108,6 +108,40 @@ export function registerPushRoutes(app: import("express").Express): void { res.status(500).json({ error: 'Ошибка обновления статуса' }); } }); + + // 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 app.get('/api/devices', authenticateToken, async (req: AuthenticatedRequest, res) => { diff --git a/server/services/notification.service.ts b/server/services/notification.service.ts index dcb64b3..7230c68 100644 --- a/server/services/notification.service.ts +++ b/server/services/notification.service.ts @@ -13,6 +13,7 @@ import { import { webPushService } from "./web-push.service"; import { eq, and, or, isNull, inArray, sql } from "drizzle-orm"; import { storage } from "../storage"; +import { eventBus } from "../routes/shared"; // Интерфейс для push-уведомления (для отправки через Pushy) export interface PushNotificationPayload { @@ -582,6 +583,32 @@ export class NotificationService { // === Push Notification Methods === + /** + * Проверяет, нужно ли подавить видимый push, потому что пользователь уже + * смотрит в соответствующий чат на активной видимой вкладке. + */ + private async shouldSkipPushForActiveChat( + userId: number, + organizationId: number, + chatType: 'messenger' | 'task', + chatId: number + ): Promise { + // Подавляем только если у пользователя есть активное 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-уведомление в очередь для офлайн-пользователей. * Вызывается после создания in-app уведомления. @@ -596,6 +623,18 @@ export class NotificationService { actions?: Array<{ action: string; title: string }> ): Promise { 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 для открытия задачи let url = '/'; if (data?.taskId && data?.formId) { @@ -716,6 +755,13 @@ export class NotificationService { // Push-уведомление: пропускаем для заглушённых чатов, // КРОМЕ случая когда пользователь упомянут через @mention 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 { await webPushService.sendToUser(memberId, orgId, { title: chatName, diff --git a/server/storage/content.storage.ts b/server/storage/content.storage.ts index cde2f02..5cc889e 100644 --- a/server/storage/content.storage.ts +++ b/server/storage/content.storage.ts @@ -394,6 +394,88 @@ export class ContentStorage extends DataTablesStorage { return userIds.filter(id => !onlineUserIds.has(id)); } + async updateUserActiveChat( + userId: number, + organizationId: number, + type: 'messenger' | 'task', + id: number, + visible: boolean + ): Promise { + 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 { + 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 async registerWebPushSubscription(userId: number, organizationId: number, endpoint: string, subscription: string): Promise { await db.delete(webPushSubscriptions).where(eq(webPushSubscriptions.endpoint, endpoint)); diff --git a/shared/schema.ts b/shared/schema.ts index 1e87ec3..3d648ee 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -2493,6 +2493,12 @@ export const userPresence = pgTable("user_presence", { isOnline: boolean("is_online").default(false), lastSeenAt: timestamp("last_seen_at").defaultNow(), 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) => ({ userIndex: index("user_presence_user_idx").on(table.userId), onlineIndex: index("user_presence_online_idx").on(table.isOnline),