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:
@@ -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);
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: {} });
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user