From 4623934f3e2226556aa2a70c3f8f81a1a9f4a30b Mon Sep 17 00:00:00 2001 From: Liam Tiernan Date: Mon, 18 Apr 2022 10:45:45 -0400 Subject: [PATCH 1/6] API Message Read Update Updated database and API to accept timestamp for read messages. Updated GET conversations endpoint to return this read timestamp. --- .../migrations/0002_message_readat.py | 18 ++++++++ server/messenger_backend/models/message.py | 1 + .../views/api/conversations.py | 42 ++++++++++++++++++- 3 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 server/messenger_backend/migrations/0002_message_readat.py diff --git a/server/messenger_backend/migrations/0002_message_readat.py b/server/messenger_backend/migrations/0002_message_readat.py new file mode 100644 index 0000000..251a0af --- /dev/null +++ b/server/messenger_backend/migrations/0002_message_readat.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.4 on 2022-04-18 12:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('messenger_backend', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='message', + name='readAt', + field=models.DateTimeField(null=True), + ), + ] diff --git a/server/messenger_backend/models/message.py b/server/messenger_backend/models/message.py index ba3082a..72dfa86 100644 --- a/server/messenger_backend/models/message.py +++ b/server/messenger_backend/models/message.py @@ -14,5 +14,6 @@ class Message(utils.CustomModel): related_name="messages", related_query_name="message" ) + readAt = models.DateTimeField(null=True) createdAt = models.DateTimeField(auto_now_add=True, db_index=True) updatedAt = models.DateTimeField(auto_now=True) \ No newline at end of file diff --git a/server/messenger_backend/views/api/conversations.py b/server/messenger_backend/views/api/conversations.py index 812e93a..2c83850 100644 --- a/server/messenger_backend/views/api/conversations.py +++ b/server/messenger_backend/views/api/conversations.py @@ -37,7 +37,7 @@ def get(self, request: Request): convo_dict = { "id": convo.id, "messages": [ - message.to_dict(["id", "text", "senderId", "createdAt"]) + message.to_dict(["id", "text", "senderId", "readAt", "createdAt"]) for message in convo.messages.all() ], } @@ -69,3 +69,43 @@ def get(self, request: Request): ) except Exception as e: return HttpResponse(status=500) + + def post(self, request: Request): + try: + user = get_user(request) + + if user.is_anonymous: + return HttpResponse(status=401) + user_id = user.id + + body = request.data + conversation_id = body.get("conversationId") + read_at = body.get("readAt") + read_messages = body.get("readMessages") + + conversation = ( + Conversation.objects.filter(id=conversation_id) + .prefetch_related( + Prefetch( + "messages", queryset=Message.objects.filter(readAt=None) + .filter(id__in=read_messages) + .exclude(senderId=user_id) + ) + ) + .first() + ) + + messages_response = [] + + for message in conversation.messages.all(): + message.readAt = read_at + messages_response.append(message.to_dict()) + + Message.objects.bulk_update(conversation.messages.all(), ['readAt']) + + return JsonResponse( + messages_response, + safe=False + ) + except Exception as e: + return HttpResponse(status=500) From ffff5aea2c42e7e27800962481508b6cf8ca2a0f Mon Sep 17 00:00:00 2001 From: Liam Tiernan Date: Mon, 18 Apr 2022 16:12:51 -0400 Subject: [PATCH 2/6] In Chat Last Read Indicator Added last read avatar indicator to last read message in chat. --- client/src/components/ActiveChat/Messages.js | 21 ++++++++++++++++++- .../src/components/ActiveChat/SenderBubble.js | 16 ++++++++++++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/client/src/components/ActiveChat/Messages.js b/client/src/components/ActiveChat/Messages.js index 62cf1f0..2ff55ce 100644 --- a/client/src/components/ActiveChat/Messages.js +++ b/client/src/components/ActiveChat/Messages.js @@ -6,13 +6,32 @@ import moment from 'moment'; const Messages = (props) => { const { messages, otherUser, userId } = props; + let lastRead; + + for (const message of messages) { + if (message.readAt && message.sendId !== userId) { + if (!lastRead || lastRead.readAt < message.readAt) { + lastRead = message; + } + } + } + + console.log(lastRead) + return ( {messages.map((message) => { const time = moment(message.createdAt).format('h:mm'); + const isLastRead = lastRead.id === message.id return message.senderId === userId ? ( - + ) : ( ({ root: { @@ -8,6 +8,13 @@ const useStyles = makeStyles(() => ({ flexDirection: 'column', alignItems: 'flex-end', }, + avatar: { + height: 20, + width: 20, + marginRight: 1, + marginTop: 9, + marginBottom: 5, + }, date: { fontSize: 11, color: '#BECCE2', @@ -27,7 +34,7 @@ const useStyles = makeStyles(() => ({ }, })); -const SenderBubble = ({ time, text }) => { +const SenderBubble = ({ isLastRead, otherUser, time, text }) => { const classes = useStyles(); return ( @@ -36,6 +43,11 @@ const SenderBubble = ({ time, text }) => { {text} + {isLastRead && } ); }; From 8094e30892a76589ab0e3101969ce295a3a327c0 Mon Sep 17 00:00:00 2001 From: Liam Tiernan Date: Wed, 20 Apr 2022 11:26:55 -0400 Subject: [PATCH 3/6] Added UI Message Read Handling Updated front end to POST to API when user reads another users message. Added socket to send these updates to other users as well --- client/src/components/ActiveChat/Messages.js | 11 +-- client/src/components/Home.js | 80 ++++++++++++++++++- client/src/components/Sidebar/Chat.js | 14 +++- .../views/api/conversations.py | 11 ++- server/socketio_app/views.py | 8 ++ 5 files changed, 113 insertions(+), 11 deletions(-) diff --git a/client/src/components/ActiveChat/Messages.js b/client/src/components/ActiveChat/Messages.js index 2ff55ce..496a0fd 100644 --- a/client/src/components/ActiveChat/Messages.js +++ b/client/src/components/ActiveChat/Messages.js @@ -9,20 +9,21 @@ const Messages = (props) => { let lastRead; for (const message of messages) { - if (message.readAt && message.sendId !== userId) { - if (!lastRead || lastRead.readAt < message.readAt) { + if (message.readAt && message.senderId === userId) { + if (!lastRead || lastRead.createdAt < message.createdAt) { lastRead = message; } } } - console.log(lastRead) - return ( {messages.map((message) => { const time = moment(message.createdAt).format('h:mm'); - const isLastRead = lastRead.id === message.id + let isLastRead = false; + if (lastRead && lastRead.id === message.id) { + isLastRead = true; + } return message.senderId === userId ? ( { } }; + const markMessagesRead = useCallback(async (conversationId, messages) => { + const readMessageIds = messages.map(message => message.id); + const readAt = new Date(); + + const body = { + conversationId, + readAt, + readMessages: readMessageIds + } + + const { data } = await axios.post("/api/conversations", body); + + socket.emit("read-messages", { conversationId, readAt, readMessageIds: data.readMessageIds }); + }, [socket]); + + const updateReadMessages = useCallback((data) => { + setConversations((prev) => + prev.map((convo) => { + if (convo.id === data.conversationId) { + const convoCopy = { ...convo, messages: [ ...convo.messages ]} + const newMessages = convoCopy.messages.map(message => { + if (data.readMessageIds.includes(message.id)) { + message.readAt = data.readAt; + } + return message; + }); + + convoCopy.messages = newMessages; + return convoCopy; + } else { + return convo; + } + }), + ); + }, [setConversations]); + const addNewConvo = useCallback( (recipientId, message) => { setConversations((prev) => { @@ -106,11 +142,14 @@ const Home = ({ user, logout }) => { id: message.conversationId, otherUser: sender, messages: [message], + unreadMessageCount: 1, }; newConvo.latestMessageText = message.text; setConversations((prev) => [newConvo, ...prev]); } + let readMessages = []; + setConversations((prev) => { return prev.map((convo) => { const convoCopy = { ...convo, messages: [ ...convo.messages ]} @@ -118,16 +157,49 @@ const Home = ({ user, logout }) => { if (convoCopy.id === message.conversationId) { convoCopy.messages.push(message); convoCopy.latestMessageText = message.text; + + if (message.senderId !== user.id) { + if (activeConversation === convoCopy.otherUser.username) { + readMessages.push(message); + } else { + convoCopy.unreadMessageCount++; + } + } } - + return convoCopy; }); }); + + if (readMessages.length > 0) { + markMessagesRead(message.conversationId, readMessages); + } }, - [setConversations], + [activeConversation, markMessagesRead, setConversations, user.id], ); const setActiveChat = (username) => { + const conversation = conversations.find(conversation => { + return conversation.otherUser.username === username; + }); + + let readMessages = conversation.messages.filter(message => { + return !message.readAt && message.senderId !== user.id; + }); + + setConversations((prev) => { + return prev.map((convo) => { + const convoCopy = { ...convo, messages: [ ...convo.messages ]} + + if (convoCopy.id === conversation.id) { + convoCopy.unreadMessageCount = 0; + } + + return convoCopy; + }); + }); + + if (readMessages.length > 0) { markMessagesRead(conversation.id, readMessages); } setActiveConversation(username); }; @@ -166,6 +238,7 @@ const Home = ({ user, logout }) => { socket.on("add-online-user", addOnlineUser); socket.on("remove-offline-user", removeOfflineUser); socket.on("new-message", addMessageToConversation); + socket.on("read-messages", updateReadMessages); return () => { // before the component is destroyed @@ -173,8 +246,9 @@ const Home = ({ user, logout }) => { socket.off("add-online-user", addOnlineUser); socket.off("remove-offline-user", removeOfflineUser); socket.off("new-message", addMessageToConversation); + socket.off("read-messages", updateReadMessages); }; - }, [addMessageToConversation, addOnlineUser, removeOfflineUser, socket]); + }, [addMessageToConversation, addOnlineUser, removeOfflineUser, socket, updateReadMessages]); useEffect(() => { // when fetching, prevent redirect diff --git a/client/src/components/Sidebar/Chat.js b/client/src/components/Sidebar/Chat.js index 77fa6ee..0d51f71 100644 --- a/client/src/components/Sidebar/Chat.js +++ b/client/src/components/Sidebar/Chat.js @@ -1,5 +1,5 @@ import React from 'react'; -import { Box } from '@material-ui/core'; +import { Box, Chip } from '@material-ui/core'; import { BadgeAvatar, ChatContent } from '../Sidebar'; import { makeStyles } from '@material-ui/core/styles'; @@ -15,6 +15,12 @@ const useStyles = makeStyles((theme) => ({ cursor: 'grab', }, }, + unread: { + fontSize: 10, + fontWeight: 'bold', + height: 20, + marginRight: 20, + } })); const Chat = ({ conversation, setActiveChat }) => { @@ -34,6 +40,12 @@ const Chat = ({ conversation, setActiveChat }) => { sidebar={true} /> + {conversation.unreadMessageCount > 0 && } ); }; diff --git a/server/messenger_backend/views/api/conversations.py b/server/messenger_backend/views/api/conversations.py index 2c83850..0ec5e27 100644 --- a/server/messenger_backend/views/api/conversations.py +++ b/server/messenger_backend/views/api/conversations.py @@ -44,6 +44,9 @@ def get(self, request: Request): # set properties for notification count and latest message preview convo_dict["latestMessageText"] = convo_dict["messages"][-1]["text"] + convo_dict["unreadMessageCount"] = sum( + message["readAt"] == None and message["senderId"] != user_id for message in convo_dict["messages"] + ) # set a property "otherUser" so that frontend will have easier access user_fields = ["id", "username", "photoUrl"] @@ -95,14 +98,18 @@ def post(self, request: Request): .first() ) - messages_response = [] + messages_response = { + "messages": [] + } for message in conversation.messages.all(): message.readAt = read_at - messages_response.append(message.to_dict()) + messages_response["messages"].append(message.to_dict()) Message.objects.bulk_update(conversation.messages.all(), ['readAt']) + messages_response["readMessageIds"] = read_messages + return JsonResponse( messages_response, safe=False diff --git a/server/socketio_app/views.py b/server/socketio_app/views.py index 0830716..217f5b8 100644 --- a/server/socketio_app/views.py +++ b/server/socketio_app/views.py @@ -30,6 +30,14 @@ def new_message(sid, message): skip_sid=sid, ) +@sio.on("read-messages") +def read_message(sid, data): + sio.emit( + "read-messages", + data, + skip_sid=sid, + ) + @sio.on("logout") def logout(sid, user_id): From 02dadf2946cc660d3b5bd9f0fc80c62b37b895d3 Mon Sep 17 00:00:00 2001 From: Liam Tiernan Date: Wed, 20 Apr 2022 15:21:42 -0400 Subject: [PATCH 4/6] Bug Fixes and Testing Fixed bugs and added cypress tests --- .../cypress/integration/read-status.spec.js | 53 +++++++++++++++++++ client/src/components/Home.js | 4 +- 2 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 client/cypress/integration/read-status.spec.js diff --git a/client/cypress/integration/read-status.spec.js b/client/cypress/integration/read-status.spec.js new file mode 100644 index 0000000..e15a697 --- /dev/null +++ b/client/cypress/integration/read-status.spec.js @@ -0,0 +1,53 @@ +/// + +const alice = { + username: "Alice", + email: "alice@example.com", + password: "Z6#6%xfLTarZ9U", +}; +const bob = { + username: "Bob", + email: "bob@example.com", + password: "L%e$xZHC4QKP@F", +}; + +describe("Read Status", () => { + it("setup", () => { + cy.signup(alice.username, alice.email, alice.password); + cy.logout(); + cy.signup(bob.username, bob.email, bob.password); + cy.logout(); + }); + + it("displays total unread", () => { + cy.login(alice.username, alice.password); + + cy.get("input[name=search]").type("Bob"); + cy.contains("Bob").click(); + + cy.get("input[name=text]").type("First message{enter}"); + cy.get("input[name=text]").type("Second message{enter}"); + cy.get("input[name=text]").type("Third message{enter}"); + + cy.logout(); + + cy.login(bob.username, bob.password); + cy.contains("3"); + cy.logout(); + }); + + it("displays last read message", () => { + cy.login(bob.username, bob.password); + cy.contains("Alice").click(); + cy.logout(); + + cy.login(alice.username, alice.password); + cy.contains("Bob").click(); + + cy.get("svg").should(($list) => { + expect($list).to.have.length(6) + }) + + cy.logout(); + }); +}); diff --git a/client/src/components/Home.js b/client/src/components/Home.js index 11352fd..adbc210 100644 --- a/client/src/components/Home.js +++ b/client/src/components/Home.js @@ -142,7 +142,7 @@ const Home = ({ user, logout }) => { id: message.conversationId, otherUser: sender, messages: [message], - unreadMessageCount: 1, + unreadMessageCount: 0, }; newConvo.latestMessageText = message.text; setConversations((prev) => [newConvo, ...prev]); @@ -189,7 +189,7 @@ const Home = ({ user, logout }) => { setConversations((prev) => { return prev.map((convo) => { - const convoCopy = { ...convo, messages: [ ...convo.messages ]} + const convoCopy = { ...convo } if (convoCopy.id === conversation.id) { convoCopy.unreadMessageCount = 0; From b476d104985e38d913d0fdbd81181a4ab4829fb3 Mon Sep 17 00:00:00 2001 From: Liam Tiernan Date: Wed, 20 Apr 2022 15:30:10 -0400 Subject: [PATCH 5/6] Testing fix --- .../integration/bug-fix-ticket.spec.js | 4 +++ .../cypress/integration/read-status.spec.js | 34 ++++++++++--------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/client/cypress/integration/bug-fix-ticket.spec.js b/client/cypress/integration/bug-fix-ticket.spec.js index 8341093..4885852 100644 --- a/client/cypress/integration/bug-fix-ticket.spec.js +++ b/client/cypress/integration/bug-fix-ticket.spec.js @@ -26,7 +26,9 @@ describe("Bug Fix: Sending Messages", () => { cy.contains("Bob").click(); cy.get("input[name=text]").type("First message{enter}"); + cy.wait(500) cy.get("input[name=text]").type("Second message{enter}"); + cy.wait(500) cy.get("input[name=text]").type("Third message{enter}"); cy.contains("First message"); @@ -60,7 +62,9 @@ describe("Bug Fix: Sending Messages", () => { cy.contains("Bob").click(); cy.get("input[name=text]").type("Fourth message{enter}"); + cy.wait(500) cy.get("input[name=text]").type("Fifth message{enter}"); + cy.wait(500) cy.get("input[name=text]").type("Sixth message{enter}"); cy.contains("Fourth message"); diff --git a/client/cypress/integration/read-status.spec.js b/client/cypress/integration/read-status.spec.js index e15a697..bd683c9 100644 --- a/client/cypress/integration/read-status.spec.js +++ b/client/cypress/integration/read-status.spec.js @@ -1,48 +1,50 @@ /// -const alice = { - username: "Alice", - email: "alice@example.com", +const ringo = { + username: "Ringo", + email: "ringo@example.com", password: "Z6#6%xfLTarZ9U", }; -const bob = { - username: "Bob", - email: "bob@example.com", +const george = { + username: "George", + email: "george@example.com", password: "L%e$xZHC4QKP@F", }; describe("Read Status", () => { it("setup", () => { - cy.signup(alice.username, alice.email, alice.password); + cy.signup(ringo.username, ringo.email, ringo.password); cy.logout(); - cy.signup(bob.username, bob.email, bob.password); + cy.signup(george.username, george.email, george.password); cy.logout(); }); it("displays total unread", () => { - cy.login(alice.username, alice.password); + cy.login(ringo.username, ringo.password); - cy.get("input[name=search]").type("Bob"); - cy.contains("Bob").click(); + cy.get("input[name=search]").type("George"); + cy.contains("George").click(); cy.get("input[name=text]").type("First message{enter}"); + cy.wait(500) cy.get("input[name=text]").type("Second message{enter}"); + cy.wait(500) cy.get("input[name=text]").type("Third message{enter}"); cy.logout(); - cy.login(bob.username, bob.password); + cy.login(george.username, george.password); cy.contains("3"); cy.logout(); }); it("displays last read message", () => { - cy.login(bob.username, bob.password); - cy.contains("Alice").click(); + cy.login(george.username, george.password); + cy.contains("Ringo").click(); cy.logout(); - cy.login(alice.username, alice.password); - cy.contains("Bob").click(); + cy.login(ringo.username, ringo.password); + cy.contains("George").click(); cy.get("svg").should(($list) => { expect($list).to.have.length(6) From 7ae7591880170ebf63175557b03a79865e351093 Mon Sep 17 00:00:00 2001 From: Liam Tiernan Date: Sun, 24 Apr 2022 12:13:05 -0400 Subject: [PATCH 6/6] Addressed PR Notes Addressed notes left on PR for performance upgrades and missing features. --- .../src/components/ActiveChat/ActiveChat.js | 1 + client/src/components/ActiveChat/Messages.js | 18 ++------------- client/src/components/Home.js | 3 ++- client/src/components/Sidebar/Chat.js | 9 ++++++-- client/src/components/Sidebar/ChatContent.js | 11 ++++++++-- .../views/api/conversations.py | 22 ++++++++++++++++--- 6 files changed, 40 insertions(+), 24 deletions(-) diff --git a/client/src/components/ActiveChat/ActiveChat.js b/client/src/components/ActiveChat/ActiveChat.js index 17a3811..8a8a00a 100644 --- a/client/src/components/ActiveChat/ActiveChat.js +++ b/client/src/components/ActiveChat/ActiveChat.js @@ -49,6 +49,7 @@ const ActiveChat = ({ {user && ( <> { - const { messages, otherUser, userId } = props; - - let lastRead; - - for (const message of messages) { - if (message.readAt && message.senderId === userId) { - if (!lastRead || lastRead.createdAt < message.createdAt) { - lastRead = message; - } - } - } + const { lastReadMessage, messages, otherUser, userId } = props; return ( {messages.map((message) => { const time = moment(message.createdAt).format('h:mm'); - let isLastRead = false; - if (lastRead && lastRead.id === message.id) { - isLastRead = true; - } - + let isLastRead = lastReadMessage && lastReadMessage === message.id; return message.senderId === userId ? ( { readMessages: readMessageIds } - const { data } = await axios.post("/api/conversations", body); + const { data } = await axios.patch("/api/conversations", body); socket.emit("read-messages", { conversationId, readAt, readMessageIds: data.readMessageIds }); }, [socket]); @@ -106,6 +106,7 @@ const Home = ({ user, logout }) => { }); convoCopy.messages = newMessages; + convoCopy.lastReadMessage = Math.max(...data.readMessageIds); return convoCopy; } else { return convo; diff --git a/client/src/components/Sidebar/Chat.js b/client/src/components/Sidebar/Chat.js index 0d51f71..e3fbb4b 100644 --- a/client/src/components/Sidebar/Chat.js +++ b/client/src/components/Sidebar/Chat.js @@ -31,6 +31,8 @@ const Chat = ({ conversation, setActiveChat }) => { await setActiveChat(conversation.otherUser.username); }; + const isUnread = conversation.unreadMessageCount > 0; + return ( handleClick(conversation)} className={classes.root}> { online={otherUser.online} sidebar={true} /> - - {conversation.unreadMessageCount > 0 && + {isUnread && ({ color: "#9CADC8", letterSpacing: -0.17, }, + unReadText: { + fontSize: 12, + fontWeight: 600, + color: "#000", + letterSpacing: -0.17, + } })); -const ChatContent = ({ conversation }) => { +const ChatContent = ({ conversation, isUnread }) => { const classes = useStyles(); const { otherUser } = conversation; const latestMessageText = conversation.id && conversation.latestMessageText; + const previewClass = isUnread ? classes.unReadText : classes.previewText; return ( @@ -32,7 +39,7 @@ const ChatContent = ({ conversation }) => { {otherUser.username} - + {latestMessageText} diff --git a/server/messenger_backend/views/api/conversations.py b/server/messenger_backend/views/api/conversations.py index 0ec5e27..c83f9b4 100644 --- a/server/messenger_backend/views/api/conversations.py +++ b/server/messenger_backend/views/api/conversations.py @@ -1,3 +1,4 @@ +from operator import attrgetter from django.contrib.auth.middleware import get_user from django.db.models import Max, Q from django.db.models.query import Prefetch @@ -42,12 +43,24 @@ def get(self, request: Request): ], } - # set properties for notification count and latest message preview + # set properties for notification count, latest message, and last read message in preview convo_dict["latestMessageText"] = convo_dict["messages"][-1]["text"] convo_dict["unreadMessageCount"] = sum( message["readAt"] == None and message["senderId"] != user_id for message in convo_dict["messages"] ) + last_read_message = None + + for message in convo_dict["messages"]: + if message["readAt"] and message["senderId"] == user_id: + if not last_read_message or last_read_message["createdAt"] < message["createdAt"]: + last_read_message = message + + if last_read_message: + convo_dict["lastReadMessage"] = last_read_message["id"] + else: + convo_dict["lastReadMessage"] = None + # set a property "otherUser" so that frontend will have easier access user_fields = ["id", "username", "photoUrl"] if convo.user1 and convo.user1.id != user_id: @@ -73,7 +86,7 @@ def get(self, request: Request): except Exception as e: return HttpResponse(status=500) - def post(self, request: Request): + def patch(self, request: Request): try: user = get_user(request) @@ -87,7 +100,7 @@ def post(self, request: Request): read_messages = body.get("readMessages") conversation = ( - Conversation.objects.filter(id=conversation_id) + Conversation.objects.filter(Q(id=conversation_id) & (Q(user1=user_id) | Q(user2=user_id))) .prefetch_related( Prefetch( "messages", queryset=Message.objects.filter(readAt=None) @@ -98,6 +111,9 @@ def post(self, request: Request): .first() ) + if not conversation: + return HttpResponse(status=403) + messages_response = { "messages": [] }