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 <noreply@anthropic.com>
This commit is contained in:
Ai
2026-05-22 13:47:38 +03:00
parent 86073c0549
commit 20daa2ca2f
6 changed files with 31 additions and 16 deletions

View File

@@ -27,13 +27,10 @@ async function getChatMemberIds(chatId: string): Promise<string[]> {
}
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);

View File

@@ -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],
});
})
);
});

View File

@@ -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

View File

@@ -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
<span className={`text-xs ml-2 float-right mt-1 flex items-center gap-0.5 ${isMine ? 'text-blue-200' : 'text-gray-400'}`}>
{message.isEdited && <span className="mr-1">ред.</span>}
{time}
{isMine && <CheckCheck className="w-3 h-3 ml-0.5" />}
{isMine && (isRead
? <CheckCheck className="w-3 h-3 ml-0.5" />
: <Check className="w-3 h-3 ml-0.5" />
)}
</span>
</div>
)}

View File

@@ -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<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(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}

View File

@@ -9,6 +9,7 @@ interface AppStore {
onlineUsers: Set<string>;
typingUsers: Record<string, string[]>;
connected: boolean;
chatReadAt: Record<string, string>; // 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<AppStore>((set, get) => ({
onlineUsers: new Set(),
typingUsers: {},
connected: false,
chatReadAt: {},
setUser: (user) => set({ user }),
@@ -120,9 +123,13 @@ export const useStore = create<AppStore>((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: {} });
},
}));