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) {
|
async function sendPushToOfflineUsers(uids: string[], payload: object) {
|
||||||
const online = new Set(connections.keys());
|
// Send to all — SW on client side will suppress if app is in foreground
|
||||||
const offline = uids.filter(id => !online.has(id));
|
|
||||||
if (offline.length === 0) return;
|
|
||||||
|
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
'SELECT endpoint, auth_key, p256dh FROM push_subscriptions WHERE user_id = ANY($1)',
|
'SELECT endpoint, auth_key, p256dh FROM push_subscriptions WHERE user_id = ANY($1)',
|
||||||
[offline]
|
[uids]
|
||||||
);
|
);
|
||||||
|
|
||||||
const body = JSON.stringify(payload);
|
const body = JSON.stringify(payload);
|
||||||
|
|||||||
@@ -29,12 +29,17 @@ self.addEventListener('push', (event) => {
|
|||||||
try { data = event.data.json(); } catch {}
|
try { data = event.data.json(); } catch {}
|
||||||
|
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
self.registration.showNotification(data.title, {
|
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clients => {
|
||||||
body: data.body,
|
// Suppress if any app window is focused
|
||||||
icon: '/icon-192.png',
|
const appFocused = clients.some(c => c.focused);
|
||||||
badge: '/icon-192.png',
|
if (appFocused) return;
|
||||||
data: { chatId: data.chatId },
|
return self.registration.showNotification(data.title, {
|
||||||
vibrate: [200, 100, 200],
|
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() {
|
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(() => {
|
useEffect(() => {
|
||||||
// Restore session
|
// Restore session
|
||||||
@@ -85,6 +85,7 @@ export default function App() {
|
|||||||
|
|
||||||
wsClient.on('messages_read', ({ chatId }) => {
|
wsClient.on('messages_read', ({ chatId }) => {
|
||||||
markRead(chatId);
|
markRead(chatId);
|
||||||
|
markReadByOther(chatId);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle SW notification click
|
// Handle SW notification click
|
||||||
|
|||||||
@@ -10,12 +10,13 @@ interface Props {
|
|||||||
isMine: boolean;
|
isMine: boolean;
|
||||||
showAvatar: boolean;
|
showAvatar: boolean;
|
||||||
isGroup: boolean;
|
isGroup: boolean;
|
||||||
|
isRead: boolean;
|
||||||
onReply: (msg: Message) => void;
|
onReply: (msg: Message) => void;
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
onEdit: (msg: Message) => 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);
|
const [showMenu, setShowMenu] = useState(false);
|
||||||
|
|
||||||
if (message.isDeleted) {
|
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'}`}>
|
<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>}
|
{message.isEdited && <span className="mr-1">ред.</span>}
|
||||||
{time}
|
{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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ function DateSeparator({ date }: { date: Date }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function MessageList({ chatId, isGroup, canSend }: Props) {
|
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 msgs = messages[chatId] || [];
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
const containerRef = 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}
|
isMine={msg.sender?.id === user?.id}
|
||||||
showAvatar={showAvatar}
|
showAvatar={showAvatar}
|
||||||
isGroup={isGroup}
|
isGroup={isGroup}
|
||||||
|
isRead={!!(chatReadAt[chatId] && msg.createdAt <= chatReadAt[chatId])}
|
||||||
onReply={handleReply}
|
onReply={handleReply}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface AppStore {
|
|||||||
onlineUsers: Set<string>;
|
onlineUsers: Set<string>;
|
||||||
typingUsers: Record<string, string[]>;
|
typingUsers: Record<string, string[]>;
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
|
chatReadAt: Record<string, string>; // chatId → ISO timestamp others last read
|
||||||
|
|
||||||
setUser: (user: User | null) => void;
|
setUser: (user: User | null) => void;
|
||||||
setChats: (chats: Chat[]) => void;
|
setChats: (chats: Chat[]) => void;
|
||||||
@@ -23,6 +24,7 @@ interface AppStore {
|
|||||||
setTyping: (chatId: string, userId: string, typing: boolean) => void;
|
setTyping: (chatId: string, userId: string, typing: boolean) => void;
|
||||||
setConnected: (v: boolean) => void;
|
setConnected: (v: boolean) => void;
|
||||||
markRead: (chatId: string) => void;
|
markRead: (chatId: string) => void;
|
||||||
|
markReadByOther: (chatId: string) => void;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +43,7 @@ export const useStore = create<AppStore>((set, get) => ({
|
|||||||
onlineUsers: new Set(),
|
onlineUsers: new Set(),
|
||||||
typingUsers: {},
|
typingUsers: {},
|
||||||
connected: false,
|
connected: false,
|
||||||
|
chatReadAt: {},
|
||||||
|
|
||||||
setUser: (user) => set({ user }),
|
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)
|
chats: state.chats.map(c => c.id === chatId ? { ...c, unreadCount: 0 } : c)
|
||||||
})),
|
})),
|
||||||
|
|
||||||
|
markReadByOther: (chatId) => set(state => ({
|
||||||
|
chatReadAt: { ...state.chatReadAt, [chatId]: new Date().toISOString() }
|
||||||
|
})),
|
||||||
|
|
||||||
logout: () => {
|
logout: () => {
|
||||||
localStorage.removeItem('jc_token');
|
localStorage.removeItem('jc_token');
|
||||||
localStorage.removeItem('jc_user');
|
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