Skip to content

Commit e26410d

Browse files
Merge pull request #162 from UNLV-CS472-672/chat-feature-improvements
PR 5: Add Seen Status, Typing Indicator, and Chat Creation Popup to Messaging
2 parents b24c564 + d07a189 commit e26410d

File tree

5,885 files changed

+578
-706285
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

5,885 files changed

+578
-706285
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
/dataSources.local.xml
99

1010
/.idea/
11+
node_modules/
12+
1113

1214
# file contains very sensitive information.
1315
# you do not want this as everybody can see this information and use it to attack the site.

backend/apps/chat/consumers.py

Lines changed: 90 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
# The below code is just for testing purposes (it works!)
2-
# Tested it using https://websocketking.com/
3-
# and entered ws://localhost:8000/ws/apps/chat/practice/
41
import json
52
from channels.generic.websocket import AsyncWebsocketConsumer
63
from asgiref.sync import sync_to_async
@@ -33,35 +30,111 @@ async def disconnect(self, close_code):
3330

3431
# This function receive messages from WebSocket.
3532
async def receive(self, text_data):
33+
# Message is received
3634
text_data_json = json.loads(text_data)
37-
message = text_data_json['message']
35+
if 'message' in text_data_json:
36+
message = text_data_json['message']
3837

39-
# Create a new message object
40-
username = await self.save_message(self.user_id, self.room_name, message)
38+
# Create a new message object
39+
username, id, timestamp = await self.save_message(self.user_id, self.room_name, message)
4140

42-
await self.channel_layer.group_send( # sends message to all users in a channel group
43-
self.room_group_name,
44-
{
45-
'type': 'chat_message', # Calls chat_message()
46-
'message': message,
47-
'username': username
48-
}
49-
)
41+
# Serialize timestamp
42+
formatted = timestamp.isoformat()
43+
44+
await self.channel_layer.group_send( # sends message to all users in a channel group
45+
self.room_group_name,
46+
{
47+
'type': 'chat_message', # Calls chat_message()
48+
'message': message,
49+
'username': username,
50+
'id': id,
51+
'timestamp': formatted
52+
}
53+
)
54+
55+
# Typing status is received
56+
elif 'typing' in text_data_json:
57+
typing = text_data_json['typing']
58+
username = await self.get_username(self.user_id)
59+
await self.channel_layer.group_send( # sends message to all users in a channel group
60+
self.room_group_name,
61+
{
62+
'type': 'typing_status', # Calls typing_status()
63+
'typing': typing,
64+
'username': username
65+
}
66+
)
67+
# Seen status is received
68+
elif 'seen' in text_data_json:
69+
sender_username, message_ids = await self.mark_as_seen(self.room_name, self.user_id)
70+
71+
await self.channel_layer.group_send( # sends message to all users in a channel group
72+
self.room_group_name,
73+
{
74+
'type': 'seen_status', # Calls seen_status()
75+
'username': sender_username,
76+
'ids': message_ids
77+
}
78+
)
79+
80+
@sync_to_async
81+
def mark_as_seen(self, room_name, user_id):
82+
chat = Chat.objects.get(name = room_name)
83+
# Get all unseen messages (excluding those from the receiver)
84+
unseen_messages = chat.messages.filter(status=Message.NOT_SEEN).exclude(sender_id=user_id).order_by('-timestamp')
85+
86+
# Get sender (assumes all chats only have 2 people)
87+
first_unseen = unseen_messages.first()
88+
sender_username = None
89+
90+
# If there are unseen messages from sender
91+
if first_unseen:
92+
sender_username = first_unseen.sender.username
93+
94+
# Get message ids (maintaining order)
95+
message_ids = list(unseen_messages.values_list('id', flat=True))
96+
97+
# Mark unseen messages as seen
98+
unseen_messages.update(status=Message.SEEN)
99+
100+
return sender_username, message_ids
101+
102+
103+
async def seen_status(self, event):
104+
await self.send(text_data=json.dumps({
105+
'message': 'seen_successful',
106+
'ids': event['ids'],
107+
'username': event['username']
108+
}))
109+
110+
async def typing_status(self, event):
111+
await self.send(text_data=json.dumps({
112+
'message': 'successful',
113+
'typing': event['typing'],
114+
'username': event['username']
115+
}))
50116

51117
async def chat_message(self, event):
52118
await self.send(text_data=json.dumps({
53119
'message': 'successful',
54120
'body': event['message'],
55-
'username': event['username']
121+
'username': event['username'],
122+
'id': event['id'],
123+
'timestamp': event['timestamp']
56124
}))
57125

126+
@sync_to_async
127+
def get_username(self, id):
128+
user = User.objects.get(id=id)
129+
return user.username
130+
58131
@sync_to_async
59132
def save_message(self, id, room_name, content):
60133
try:
61134
user = User.objects.get(id=id)
62135
chat = Chat.objects.get(name = room_name)
63-
Message.objects.create(content=content, chat = chat, sender = user) # Creates and saves message
64-
return user.username
136+
message = Message.objects.create(content=content, chat = chat, sender = user) # Creates and saves message
137+
return user.username, message.id, message.timestamp
65138
except (Chat.DoesNotExist):
66139
print(f"Chat room '{room_name}' not found. Message not saved.")
67140
return "Unknown"

backend/apps/chat/jwt_middleware.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def get_token_from_subprotocol(self, scope):
2525
# WebSocket subprotocols are sent as a list of protocols during the handshake.
2626

2727
subprotocols = scope.get("subprotocols", [])
28-
if subprotocols:
28+
if len(subprotocols) > 1:
2929
# Assuming the token is the second protocol (after the real protocol)
3030
return subprotocols[1] # The access token is the second subprotocol
3131
return None
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 5.1.6 on 2025-04-15 04:31
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('chat', '0006_chat_name'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='message',
15+
name='status',
16+
field=models.PositiveSmallIntegerField(choices=[(1, 'Seen'), (2, 'Not seen'), (3, 'Not sent')], default=2),
17+
),
18+
]

backend/apps/chat/models.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,24 @@ def __str__(self):
2020
return f"Chat between {self.user1} and {self.user2}"
2121

2222
class Message(models.Model):
23+
SEEN = 1
24+
NOT_SEEN = 2
25+
NOT_SENT = 3
26+
STATUS_CHOICES = (
27+
(SEEN, 'Seen'),
28+
(NOT_SEEN, 'Not seen'),
29+
(NOT_SENT, 'Not sent'),
30+
)
2331
chat = models.ForeignKey(Chat, on_delete=models.CASCADE, related_name="messages")
32+
status = models.PositiveSmallIntegerField(choices=STATUS_CHOICES, null=False, blank=False, default=NOT_SEEN)
2433
sender = models.ForeignKey(User, on_delete=models.CASCADE, related_name="sent_messages")
2534
content = models.TextField()
2635
timestamp = models.DateTimeField(default=now)
2736

2837
objects = MessageManager()
2938

3039
class Meta:
31-
ordering = ['timestamp']
40+
ordering = ['timestamp']
3241

3342
def __str__(self):
3443
return f"From {self.sender} in Chat {self.chat.id} at {self.timestamp}"

backend/apps/chat/tests.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@
33
from rest_framework import status
44
from rest_framework.test import APITestCase, APIClient
55
from .models import Chat, Message
6+
from apps.chat.consumers import ChatConsumer
7+
8+
def test_chat_consumer_init():
9+
consumer = ChatConsumer()
10+
assert consumer is not None
611

712
class ChatAPITestCase(APITestCase):
813

backend/codecov.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
coverage:
2+
status:
3+
project:
4+
default:
5+
enabled: false
6+
patch:
7+
default:
8+
enabled: false
9+
10+
comment:
11+
layout: "reach, diff, flags, files"
12+
behavior: default
13+
require_changes: false
14+
after_n_builds: 1

frontend/src/App.jsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useEffect } from "react";
33
import Header from "./Components/Header.jsx";
44
import Footer from "./Components/Footer.jsx";
55
import StudentLayout from "./Components/StudentLayout.jsx";
6+
import '@fortawesome/fontawesome-free/css/all.min.css';
67

78

89
// Components

0 commit comments

Comments
 (0)