From 20daa2ca2fbec239f49fee38c7070bdd7f372a2c Mon Sep 17 00:00:00 2001 From: Ai Date: Fri, 22 May 2026 13:47:38 +0300 Subject: [PATCH] fix: read receipts (single/double check), push notifications for all users - Track when others read messages via messages_read WS event - Show Check (single) for sent, CheckCheck (double) only when read - Send push to all recipients, SW suppresses if app is focused Co-Authored-By: Claude Sonnet 4.6 --- backend/src/ws.ts | 7 ++----- frontend/public/sw.js | 17 +++++++++++------ frontend/src/App.tsx | 3 ++- frontend/src/components/MessageItem.tsx | 8 ++++++-- frontend/src/components/MessageList.tsx | 3 ++- frontend/src/store/index.ts | 9 ++++++++- 6 files changed, 31 insertions(+), 16 deletions(-) diff --git a/backend/src/ws.ts b/backend/src/ws.ts index f64b447..c2d3467 100644 --- a/backend/src/ws.ts +++ b/backend/src/ws.ts @@ -27,13 +27,10 @@ async function getChatMemberIds(chatId: string): Promise { } async function sendPushToOfflineUsers(uids: string[], payload: object) { - const online = new Set(connections.keys()); - const offline = uids.filter(id => !online.has(id)); - if (offline.length === 0) return; - + // Send to all — SW on client side will suppress if app is in foreground const { rows } = await pool.query( 'SELECT endpoint, auth_key, p256dh FROM push_subscriptions WHERE user_id = ANY($1)', - [offline] + [uids] ); const body = JSON.stringify(payload); diff --git a/frontend/public/sw.js b/frontend/public/sw.js index 1c34251..5df59ad 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -29,12 +29,17 @@ self.addEventListener('push', (event) => { try { data = event.data.json(); } catch {} event.waitUntil( - self.registration.showNotification(data.title, { - body: data.body, - icon: '/icon-192.png', - badge: '/icon-192.png', - data: { chatId: data.chatId }, - vibrate: [200, 100, 200], + self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clients => { + // Suppress if any app window is focused + const appFocused = clients.some(c => c.focused); + if (appFocused) return; + return self.registration.showNotification(data.title, { + body: data.body, + icon: '/icon-192.png', + badge: '/icon-192.png', + data: { chatId: data.chatId }, + vibrate: [200, 100, 200], + }); }) ); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 707a05e..6005936 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -31,7 +31,7 @@ function AuthGuard({ children }: { children: React.ReactNode }) { } export default function App() { - const { setUser, setChats, addMessage, updateMessage, removeMessage, setOnline, setTyping, setConnected, markRead } = useStore(); + const { setUser, setChats, addMessage, updateMessage, removeMessage, setOnline, setTyping, setConnected, markRead, markReadByOther } = useStore(); useEffect(() => { // Restore session @@ -85,6 +85,7 @@ export default function App() { wsClient.on('messages_read', ({ chatId }) => { markRead(chatId); + markReadByOther(chatId); }); // Handle SW notification click diff --git a/frontend/src/components/MessageItem.tsx b/frontend/src/components/MessageItem.tsx index 45ba3cb..947e32f 100644 --- a/frontend/src/components/MessageItem.tsx +++ b/frontend/src/components/MessageItem.tsx @@ -10,12 +10,13 @@ interface Props { isMine: boolean; showAvatar: boolean; isGroup: boolean; + isRead: boolean; onReply: (msg: Message) => void; onDelete: (id: string) => void; onEdit: (msg: Message) => void; } -export default function MessageItem({ message, isMine, showAvatar, isGroup, onReply, onDelete, onEdit }: Props) { +export default function MessageItem({ message, isMine, showAvatar, isGroup, isRead, onReply, onDelete, onEdit }: Props) { const [showMenu, setShowMenu] = useState(false); if (message.isDeleted) { @@ -101,7 +102,10 @@ export default function MessageItem({ message, isMine, showAvatar, isGroup, onRe {message.isEdited && ред.} {time} - {isMine && } + {isMine && (isRead + ? + : + )} )} diff --git a/frontend/src/components/MessageList.tsx b/frontend/src/components/MessageList.tsx index 0842e06..d12789a 100644 --- a/frontend/src/components/MessageList.tsx +++ b/frontend/src/components/MessageList.tsx @@ -27,7 +27,7 @@ function DateSeparator({ date }: { date: Date }) { } export default function MessageList({ chatId, isGroup, canSend }: Props) { - const { messages, setMessages, prependMessages, user, removeMessage, updateMessage } = useStore(); + const { messages, setMessages, prependMessages, user, removeMessage, updateMessage, chatReadAt } = useStore(); const msgs = messages[chatId] || []; const bottomRef = useRef(null); const containerRef = useRef(null); @@ -127,6 +127,7 @@ export default function MessageList({ chatId, isGroup, canSend }: Props) { isMine={msg.sender?.id === user?.id} showAvatar={showAvatar} isGroup={isGroup} + isRead={!!(chatReadAt[chatId] && msg.createdAt <= chatReadAt[chatId])} onReply={handleReply} onDelete={handleDelete} onEdit={handleEdit} diff --git a/frontend/src/store/index.ts b/frontend/src/store/index.ts index 8fd0b2b..57fbbcd 100644 --- a/frontend/src/store/index.ts +++ b/frontend/src/store/index.ts @@ -9,6 +9,7 @@ interface AppStore { onlineUsers: Set; typingUsers: Record; connected: boolean; + chatReadAt: Record; // chatId → ISO timestamp others last read setUser: (user: User | null) => void; setChats: (chats: Chat[]) => void; @@ -23,6 +24,7 @@ interface AppStore { setTyping: (chatId: string, userId: string, typing: boolean) => void; setConnected: (v: boolean) => void; markRead: (chatId: string) => void; + markReadByOther: (chatId: string) => void; logout: () => void; } @@ -41,6 +43,7 @@ export const useStore = create((set, get) => ({ onlineUsers: new Set(), typingUsers: {}, connected: false, + chatReadAt: {}, setUser: (user) => set({ user }), @@ -120,9 +123,13 @@ export const useStore = create((set, get) => ({ chats: state.chats.map(c => c.id === chatId ? { ...c, unreadCount: 0 } : c) })), + markReadByOther: (chatId) => set(state => ({ + chatReadAt: { ...state.chatReadAt, [chatId]: new Date().toISOString() } + })), + logout: () => { localStorage.removeItem('jc_token'); localStorage.removeItem('jc_user'); - set({ user: null, chats: [], activeChat: null, messages: {}, connected: false }); + set({ user: null, chats: [], activeChat: null, messages: {}, connected: false, chatReadAt: {} }); }, }));