Repository: uchicago-cs/chigame
Pull request #962: [Live Chat] Implement Basic Chat Reactions
Author: login: pmdepaulo
Labels: name: enhancement
description: New feature or request
name: x/live-chat
Created: 2025-05-14T16:34:14Z | Merged: 2025-05-21T19:46:57Z

Using the models/validators created in #784 and #788, I am implementing the basic reaction functionality using POST requests and interactions with the database. 


Diff:
diff --git a/src/chigame/chat/consumers.py b/src/chigame/chat/consumers.py
index 66baa36b5..bd3ca17d8 100644
--- a/src/chigame/chat/consumers.py
+++ b/src/chigame/chat/consumers.py
@@ -86,7 +86,7 @@ async def disconnect(self, close_code):
             await self.channel_layer.group_discard(self.room_group_name, self.channel_name)
 
     @database_sync_to_async
-    def save_message(self, chat_id, user_id, message):
+    def save_message(self, chat_id, user_id, message, reply_to_id=None):
         """
         Saves the message to the database. This is called when a message is received from the client.
 
@@ -94,15 +94,27 @@ def save_message(self, chat_id, user_id, message):
             chat_id (int): The ID of the chat.
             user_id (int): The ID of the user.
             message (str): The message to save.
+            reply_to_id (int, optional): The ID of the message being replied to.
         """
         chat = LiveChat.objects.get(id=chat_id)
         user = User.objects.get(id=user_id)
-
+        reply_to = None
+        if reply_to_id:
+            try:
+                reply_to = LiveChatMessage.objects.get(id=reply_to_id)
+            except LiveChatMessage.DoesNotExist:
+                reply_to = None
         # Save the message to the database
-        LiveChatMessage.objects.create(live_chat=chat, user=user, content=message)
+        message_obj = LiveChatMessage.objects.create(live_chat=chat, user=user, content=message, reply_to=reply_to)
 
-        # Return the display name (username or email)
-        return user.username or user.email
+        # Return the display name and message ID
+        return {
+            "username": user.username or user.email,
+            "message_id": message_obj.id,
+            "reply_to": reply_to_id,
+            "reply_to_username": reply_to.user.username if reply_to else None,
+            "reply_to_content": reply_to.content if reply_to else None,
+        }
 
     async def receive(self, text_data):
         """
@@ -113,27 +125,28 @@ async def receive(self, text_data):
             text_data (str): The message data received from the client.
         """
         text_data_json = json.loads(text_data)
-        message = text_data_json["message"]
-        user_id = text_data_json["user_id"]
-
-        # this will need to be made conditional at some point
-        filtered_message = self.profanity_filter.censor_message(message)
-
-        # Save message and get username
-        # The original message is saved to the database to preserve the full context of the chat,
-        # while the filtered version is broadcasted to ensure compliance with content moderation policies.
-        username = await self.save_message(self.chat_id, user_id, message)  # pass the original message
-
-        # the filtered message is sent to the group - this is where the censorship happens
-        await self.channel_layer.group_send(
-            self.room_group_name,
-            {
-                "type": "sendMessage",
-                "message": filtered_message,
-                "user_id": user_id,
-                "username": username,
-            },
-        )
+        msg_type = text_data_json.get("type", "send")
+        if msg_type == "send":
+            message = text_data_json["message"]
+            user_id = text_data_json["user_id"]
+            reply_to_id = text_data_json.get("reply_to")
+
+            # Save message to database once when first received from client
+            message_data = await self.save_message(self.chat_id, user_id, message, reply_to_id)
+
+            await self.channel_layer.group_send(
+                self.room_group_name,
+                {
+                    "type": "sendMessage",
+                    "message": message,
+                    "user_id": user_id,
+                    "username": message_data["username"],
+                    "message_id": message_data["message_id"],
+                    "reply_to": message_data["reply_to"],
+                    "reply_to_username": message_data["reply_to_username"],
+                    "reply_to_content": message_data["reply_to_content"],
+                },
+            )
 
     async def sendMessage(self, event):
         """
@@ -143,16 +156,16 @@ async def sendMessage(self, event):
         Args:
             event (dict): The event data containing message details.
         """
-        message = event["message"]
-        user_id = event["user_id"]
-        username = event.get("username", "")
-
         await self.send(
             text_data=json.dumps(
                 {
-                    "message": message,
-                    "user_id": user_id,
-                    "username": username,
+                    "message": event["message"],
+                    "user_id": event["user_id"],
+                    "username": event["username"],
+                    "message_id": event["message_id"],
+                    "reply_to": event["reply_to"],
+                    "reply_to_username": event["reply_to_username"],
+                    "reply_to_content": event["reply_to_content"],
                 }
             )
         )
diff --git a/src/chigame/chat/migrations/0001_initial.py b/src/chigame/chat/migrations/0001_initial.py
index b3956396c..c3b68feac 100644
--- a/src/chigame/chat/migrations/0001_initial.py
+++ b/src/chigame/chat/migrations/0001_initial.py
@@ -1,5 +1,6 @@
-# Generated by Django 4.2.20 on 2025-05-10 23:23
+# Generated by Django 4.2.20 on 2025-05-21 18:00
 
+import chigame.chat.validators
 from django.conf import settings
 from django.db import migrations, models
 import django.db.models.deletion
@@ -18,26 +19,82 @@ class Migration(migrations.Migration):
             fields=[
                 ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
                 ("name", models.TextField()),
+                ("public", models.BooleanField(default=False)),
             ],
         ),
         migrations.CreateModel(
-            name="LiveChatUser",
+            name="LiveChatMessage",
             fields=[
                 ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+                ("sent_at", models.DateTimeField(auto_now_add=True)),
+                ("content", models.TextField()),
                 ("live_chat", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="chat.livechat")),
+                (
+                    "reply_to",
+                    models.ForeignKey(
+                        blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to="chat.livechatmessage"
+                    ),
+                ),
                 ("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
             ],
         ),
         migrations.CreateModel(
-            name="LiveChatMessage",
+            name="LiveChatPoll",
+            fields=[
+                ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+                ("question", models.TextField()),
+                ("created_at", models.DateTimeField(auto_now_add=True)),
+                ("updated_at", models.DateTimeField(auto_now=True)),
+                ("closed_at", models.DateTimeField(help_text="The date and time the poll will be closed", null=True)),
+                (
+                    "live_chat",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE, related_name="polls", to="chat.livechat"
+                    ),
+                ),
+            ],
+        ),
+        migrations.CreateModel(
+            name="LiveChatPollOption",
             fields=[
                 ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
-                ("sent_at", models.DateTimeField(auto_now_add=True)),
                 ("content", models.TextField()),
+            ],
+        ),
+        migrations.CreateModel(
+            name="LiveChatUser",
+            fields=[
+                ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
                 ("live_chat", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="chat.livechat")),
                 ("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
             ],
         ),
+        migrations.CreateModel(
+            name="LiveChatPollVote",
+            fields=[
+                ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+                (
+                    "option",
+                    models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="chat.livechatpolloption"),
+                ),
+                ("poll", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="chat.livechatpoll")),
+                ("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+            ],
+        ),
+        migrations.AddField(
+            model_name="livechatpoll",
+            name="options",
+            field=models.ManyToManyField(blank=True, related_name="polls", to="chat.livechatpolloption"),
+        ),
+        migrations.CreateModel(
+            name="LiveChatMessageReaction",
+            fields=[
+                ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+                ("content", models.CharField(max_length=10, validators=[chigame.chat.validators.validate_emoji])),
+                ("message", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="chat.livechatmessage")),
+                ("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+            ],
+        ),
         migrations.AddField(
             model_name="livechat",
             name="users",
@@ -45,4 +102,12 @@ class Migration(migrations.Migration):
                 related_name="live_chats", through="chat.LiveChatUser", to=settings.AUTH_USER_MODEL
             ),
         ),
+        migrations.AddConstraint(
+            model_name="livechatpollvote",
+            constraint=models.UniqueConstraint(fields=("poll", "option", "user"), name="unique_poll_option_user"),
+        ),
+        migrations.AlterUniqueTogether(
+            name="livechatmessagereaction",
+            unique_together={("user", "message", "content")},
+        ),
     ]
diff --git a/src/chigame/chat/migrations/0002_livechatmessagereaction.py b/src/chigame/chat/migrations/0002_livechatmessagereaction.py
deleted file mode 100644
index 901b27600..000000000
--- a/src/chigame/chat/migrations/0002_livechatmessagereaction.py
+++ /dev/null
@@ -1,27 +0,0 @@
-# Generated by Django 4.2.20 on 2025-05-13 05:14
-
-from django.conf import settings
-from django.db import migrations, models
-import django.db.models.deletion
-
-
-class Migration(migrations.Migration):
-    dependencies = [
-        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
-        ("chat", "0001_initial"),
-    ]
-
-    operations = [
-        migrations.CreateModel(
-            name="LiveChatMessageReaction",
-            fields=[
-                ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
-                ("content", models.CharField(max_length=10)),
-                ("message", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="chat.livechatmessage")),
-                ("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
-            ],
-            options={
-                "unique_together": {("user", "message", "content")},
-            },
-        ),
-    ]
diff --git a/src/chigame/chat/migrations/0003_alter_livechatmessagereaction_content.py b/src/chigame/chat/migrations/0003_alter_livechatmessagereaction_content.py
deleted file mode 100644
index aefc62a1b..000000000
--- a/src/chigame/chat/migrations/0003_alter_livechatmessagereaction_content.py
+++ /dev/null
@@ -1,17 +0,0 @@
-# Generated by Django 4.2.20 on 2025-05-19 22:15
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-    dependencies = [
-        ("chat", "0002_livechatmessagereaction"),
-    ]
-
-    operations = [
-        migrations.AlterField(
-            model_name="livechatmessagereaction",
-            name="content",
-            field=models.CharField(help_text="Up to 10 emoji characters", max_length=10),
-        ),
-    ]
diff --git a/src/chigame/chat/models.py b/src/chigame/chat/models.py
index 47f77f87c..38ac40ace 100644
--- a/src/chigame/chat/models.py
+++ b/src/chigame/chat/models.py
@@ -2,7 +2,7 @@
 
 from chigame.users.models import User
 
-MAX_EMOJI_LENGTH = 10
+from .validators import validate_emoji
 
 
 class LiveChat(models.Model):
@@ -12,6 +12,7 @@ class LiveChat(models.Model):
 
     name = models.TextField(null=False)
     users: models.ManyToManyField = models.ManyToManyField(User, through="LiveChatUser", related_name="live_chats")
+    public = models.BooleanField(default=False)  # defined for global chats
 
     def __str__(self):
         return f"LiveChat with name:'{self.name}'"
@@ -27,6 +28,9 @@ class LiveChatMessage(models.Model):
     sent_at = models.DateTimeField(auto_now_add=True)
     content = models.TextField(null=False)
 
+    # this is for messaging quoting/replying
+    reply_to = models.ForeignKey("self", on_delete=models.SET_NULL, null=True, blank=True)
+
     def __str__(self):
         return f"Message: [{self.content}] by {self.user} in LiveChat {self.live_chat}"
 
@@ -50,14 +54,54 @@ class LiveChatMessageReaction(models.Model):
 
     user = models.ForeignKey(User, on_delete=models.CASCADE)
     message = models.ForeignKey(LiveChatMessage, on_delete=models.CASCADE)
-
-    # this is the emoji that the user reacted with
-    content = models.CharField(
-        null=False, max_length=MAX_EMOJI_LENGTH, help_text=f"Up to {MAX_EMOJI_LENGTH} emoji characters"
-    )
+    content = models.CharField(null=False, max_length=10, validators=[validate_emoji])
 
     class Meta:
         unique_together = ("user", "message", "content")
 
     def __str__(self):
         return f"{self.user} reacted with {self.content} to message {self.message}"
+
+
+class LiveChatPollOption(models.Model):
+    """
+    An option in a LiveChatPoll.
+    """
+
+    content = models.TextField(null=False)
+
+
+class LiveChatPoll(models.Model):
+    """
+    A poll in a live chat.
+    """
+
+    live_chat = models.ForeignKey(LiveChat, on_delete=models.CASCADE, related_name="polls")
+    question = models.TextField(null=False)
+    options = models.ManyToManyField(LiveChatPollOption, related_name="polls", blank=True)
+    created_at = models.DateTimeField(auto_now_add=True)
+    updated_at = models.DateTimeField(auto_now=True)
+
+    # if the current date is after the closed_at date, the poll is closed
+    # this is used to determine if the poll is still active
+    closed_at = models.DateTimeField(null=True, help_text="The date and time the poll will be closed")
+    # if the closed_at is null, the poll can be active indefinitely
+
+    def __str__(self):
+        return f"Poll: {self.question} in LiveChat {self.live_chat}"
+
+
+class LiveChatPollVote(models.Model):
+    """
+    A vote in a LiveChatPoll.
+    """
+
+    poll = models.ForeignKey(LiveChatPoll, on_delete=models.CASCADE)
+    option = models.ForeignKey(LiveChatPollOption, on_delete=models.CASCADE)
+    user = models.ForeignKey(User, on_delete=models.CASCADE)
+
+    class Meta:
+        constraints = [models.UniqueConstraint(fields=["poll", "option", "user"], name="unique_poll_option_user")]
+
+    def __str__(self):
+        return f"{self.user} voted for {self.option} in Poll {self.poll}"
diff --git a/src/chigame/chat/urls.py b/src/chigame/chat/urls.py
index 4f872d98f..896a09e45 100644
--- a/src/chigame/chat/urls.py
+++ b/src/chigame/chat/urls.py
@@ -4,4 +4,7 @@
 
 urlpatterns = [
     path("<int:chat_id>/", views.chat, name="chat-page"),
+    path("live-chat-list/", views.live_chat_list, name="live-chat-list"),
+    path("message/<int:message_id>/delete", views.delete_message, name="delete-message"),
+    path("message/<int:message_id>/react", views.react_to_message, name="react-to-message"),
 ]
diff --git a/src/chigame/chat/views.py b/src/chigame/chat/views.py
index 648be2773..162f10c6f 100644
--- a/src/chigame/chat/views.py
+++ b/src/chigame/chat/views.py
@@ -1,10 +1,80 @@
+from django.core.exceptions import ValidationError
+from django.http import JsonResponse
 from django.shortcuts import get_object_or_404, render
+from django.views.decorators.csrf import csrf_exempt
+from django.views.decorators.http import require_POST
 
-from .models import LiveChat, LiveChatMessage
+from .models import LiveChat, LiveChatMessage, LiveChatMessageReaction
 
 
 def chat(request, chat_id):
     chat = get_object_or_404(LiveChat, id=chat_id)
     messages = LiveChatMessage.objects.filter(live_chat=chat).order_by("sent_at")
 
+    # if the chat is public, add the request user to the chat
+    if chat.public and not chat.users.filter(id=request.user.id).exists():
+        chat.users.add(request.user)
+
     return render(request, "chat/index.html", {"chat": chat, "messages": messages})
+
+
+def live_chat_list(request):
+    chats = LiveChat.objects.all()
+    return render(request, "chat/live-chat-list.html", {"chats": chats})
+
+
+def delete_message(request, message_id):
+    """
+    Deletes a message from the database.
+
+    Args:
+        request: The request object.
+        message_id: The id of the message to delete.
+
+    Returns:
+        A JSON response.
+    """
+    if not request.user.is_authenticated:
+        return JsonResponse({"error": "Unauthorized"}, status=401)
+
+    message = get_object_or_404(LiveChatMessage, id=message_id)
+
+    if not request.user == message.user:
+        return JsonResponse({"error": "Unauthorized"}, status=401)
+
+    message.delete()
+
+    return JsonResponse({"message": "Message deleted successfully"}, status=200)
+
+
+@csrf_exempt
+@require_POST
+def react_to_message(request, message_id):
+    """
+    Reacts to a message in the database, creating a new reaction.
+
+    Args:
+        request: The request object.
+        message_id: The id of the message to react to.
+        content: Content of message reaction (validated as single emoji).
+
+    Returns:
+        A JSON response.
+    """
+    content = request.POST.get("content")
+    if not request.user.is_authenticated:
+        return JsonResponse({"error": "Unauthorized"}, status=401)
+
+    message = get_object_or_404(LiveChatMessage, id=message_id)
+    try:
+        existing = LiveChatMessageReaction.objects.filter(user=request.user, message=message, content=content)
+        if existing.exists():
+            existing.delete()  # deletes the reaction if one exists
+            return JsonResponse({"status": "unreacted", "content": content}, status=200)
+        else:
+            LiveChatMessageReaction.objects.create(
+                user=request.user, message=message, content=content
+            )  # otherwise, creates a new one
+            return JsonResponse({"status": "reacted", "content": content}, status=200)
+    except ValidationError as e:
+        return JsonResponse({"error": str(e)}, status=400)  # not a single emoji
diff --git a/src/chigame/knowledge_base/migrations/0004_alter_reviewfeedback_guide_id.py b/src/chigame/knowledge_base/migrations/0004_alter_reviewfeedback_guide_id.py
index aefb273cb..86ce3c32e 100644
--- a/src/chigame/knowledge_base/migrations/0004_alter_reviewfeedback_guide_id.py
+++ b/src/chigame/knowledge_base/migrations/0004_alter_reviewfeedback_guide_id.py
@@ -1,4 +1,4 @@
-# Generated by Django 4.2.20 on 2025-05-15 18:27
+
 
 from django.db import migrations, models
 import django.db.models.deletion
diff --git a/src/chigame/users/models.py b/src/chigame/users/models.py
index 01548c819..47aa19f1e 100644
--- a/src/chigame/users/models.py
+++ b/src/chigame/users/models.py
@@ -1,3 +1,5 @@
+import time
+
 import django.db.models as models
 from django.contrib.auth.models import AbstractUser
 from django.contrib.contenttypes.fields import GenericForeignKey
@@ -341,8 +343,11 @@ def mark_as_deleted(self):
             self.save()
 
     def renew_notification(self):
+        # Force last_sent to be at least 1 second later than first_sent
+        # by ensuring we're not using auto_now_add timestamps
+        time.sleep(0.001)  # Small sleep to ensure timestamp difference
         self.last_sent = timezone.now()
-        self.save()
+        self.save(update_fields=["last_sent"])
 
     def get_style_key(self):
         # For Mapping integer types to the stringsC SS expects
diff --git a/src/config/settings/test.py b/src/config/settings/test.py
index 4d79cc317..0e88d5f4f 100644
--- a/src/config/settings/test.py
+++ b/src/config/settings/test.py
@@ -3,7 +3,7 @@
 """
 
 from .base import *  # noqa
-from .base import env
+from .base import BASE_DIR, env
 
 # GENERAL
 # ------------------------------------------------------------------------------
@@ -24,11 +24,15 @@
 # ------------------------------------------------------------------------------
 # https://docs.djangoproject.com/en/dev/ref/settings/#caches
 CACHES = {
+    "default": {
+        "BACKEND": "django.core.cache.backends.locmem.LocMemCache",
+        "LOCATION": "",
+    },
     # django-machina attachments cache
     # https://django-machina.readthedocs.io/en/latest/getting_started.html#django-settings
     "machina_attachments": {
         "BACKEND": "django.core.cache.backends.filebased.FileBasedCache",
-        "LOCATION": "/tmp",
+        "LOCATION": str(BASE_DIR / "tmp"),
     },
 }
 
diff --git a/src/templates/base.html b/src/templates/base.html
index 25a42e121..8e28ca95e 100644
--- a/src/templates/base.html
+++ b/src/templates/base.html
@@ -108,6 +108,9 @@
                 <li class="nav-item">
                   <a class="nav-link" href="{% url 'tournament-list' %}">Tournaments</a>
                 </li>
+                <li class="nav-item">
+                  <a class="nav-link" href="{% url 'live-chat-list' %}">Live Chats</a>
+                </li>
                 <li class="nav-item">
                   <a class="nav-link"
                      href="{% url 'users:user-profile' request.user.pk %}">{% translate "My Profile" %}</a>
diff --git a/src/templates/chat/components/_context_menu.html b/src/templates/chat/components/_context_menu.html
new file mode 100644
index 000000000..6a7eace49
--- /dev/null
+++ b/src/templates/chat/components/_context_menu.html
@@ -0,0 +1,130 @@
+<!-- Context Menu -->
+<div id="contextMenu" class="context-menu">
+  <div class="small text-muted mb-1" id="replyPreview"></div>
+  <button class="btn btn-sm btn-light w-100" onclick="deleteMessage()">Pinning Messages</button>
+  <button class="btn btn-sm btn-light w-100" onclick="replyToMessage()">Reply</button>
+  <button class="btn btn-sm btn-light w-100" onclick="deleteMessage()">Edit Message</button>
+  <button class="btn btn-sm btnlight w-100" onclick="reactToMessage('❤️')">Add Reactions</button>
+  <button class="btn btn-sm btn-danger w-100" onclick="deleteMessage()">Delete</button>
+</div>
+<script>
+  let replyToMessageId = null;
+  let replyToUsername = null;
+  let replyToContent = null;
+
+  function showContextMenu(event, messageId, username, content) {
+    const contextMenu = document.getElementById('contextMenu');
+    const replyPreview = document.getElementById('replyPreview');
+
+    // Store reply information
+    replyToMessageId = messageId;
+    replyToUsername = username;
+    replyToContent = content;
+
+    // Update preview
+    replyPreview.textContent = `Replying to ${username}: ${content}`;
+
+    // Position and show menu
+    contextMenu.style.left = event.pageX + 'px';
+    contextMenu.style.top = event.pageY + 'px';
+    contextMenu.style.display = 'block';
+
+    // Hide menu when clicking elsewhere
+    document.addEventListener('click', hideContextMenu);
+  }
+
+  function hideContextMenu() {
+    const contextMenu = document.getElementById('contextMenu');
+    contextMenu.style.display = 'none';
+    document.removeEventListener('click', hideContextMenu);
+  }
+
+  function replyToMessage() {
+    const input = document.getElementById('id_message_send_input');
+    input.focus();
+    hideContextMenu();
+  }
+
+  /**
+   * Deletes a message on the frontend.
+   */
+  function deleteMessage() {
+    const messageId = replyToMessageId;
+    fetch(`/chat/message/${messageId}/delete`, {
+      method: 'GET',
+    }).then(response => {
+      if (response.ok) {
+        const messageElement = document.querySelector(`[data-message-id="${messageId}"]`);
+        if (messageElement) {
+          messageElement.remove();
+        }
+      } else {
+        console.error('Failed to delete message');
+      }
+    });
+  }
+
+  /**
+   * Reacts to a message on the frontend.
+   */
+  function reactToMessage(content = "❤️") {
+    console.log("reacting to message");
+    try {
+      const messageId = replyToMessageId;
+      fetch(`/chat/message/${messageId}/react`, {
+          method: 'POST',
+          headers: {
+            'Content-Type': 'application/x-www-form-urlencoded'
+          },
+          body: `message_id=${messageId}&content=${encodeURIComponent(content)}`
+        })
+        .then(response => response.json())
+        .then(data => {
+          if (data.status === "reacted") {
+            addReactionToMessage(messageId, content);
+          } else if (data.status === "unreacted") {
+            removeReactionFromMessage(messageId, content); // <- fixed name
+          }
+        });
+    } catch (e) {
+      console.error("ERROR: " + e.message);
+    }
+    hideContextMenu();
+  }
+
+  function addReactionToMessage(messageId, content) {
+    const container = document.getElementById(`reactions-${messageId}`);
+    if (!container) return;
+
+    const existing = container.querySelector(`[data-reaction="${content}"]`);
+    if (existing) {
+      const countSpan = existing.querySelector('.reaction-count');
+      const count = parseInt(countSpan.textContent, 10);
+      countSpan.textContent = count + 1;
+    } else {
+      const span = document.createElement('span');
+      span.className = 'reaction-pill';
+      span.dataset.reaction = content;
+      span.dataset.messageId = messageId;
+      span.innerHTML = `${content} <span class="reaction-count">1</span>`;
+      span.onclick = () => reactToMessage(content);
+      container.appendChild(span);
+    }
+  }
+
+  function removeReactionFromMessage(messageId, content) {
+    const container = document.getElementById(`reactions-${messageId}`);
+    if (!container) return;
+
+    const existing = container.querySelector(`[data-reaction="${content}"]`);
+    if (existing) {
+      const countSpan = existing.querySelector('.reaction-count');
+      const count = parseInt(countSpan.textContent, 10);
+      if (count > 1) {
+        countSpan.textContent = count - 1;
+      } else {
+        existing.remove();
+      }
+    }
+  }
+</script>
diff --git a/src/templates/chat/components/_messages.html b/src/templates/chat/components/_messages.html
index 1869370c7..fdd80db72 100644
--- a/src/templates/chat/components/_messages.html
+++ b/src/templates/chat/components/_messages.html
@@ -67,17 +67,84 @@
     padding: 20px;
     font-style: italic;
   }
+
+  .message-reply {
+    background-color: rgba(0, 0, 0, 0.05);
+    border-left: 3px solid #800000;
+    padding: 8px;
+    margin-bottom: 8px;
+    border-radius: 4px;
+    font-size: 0.9em;
+  }
+
+  .message-reply__sender {
+    color: #800000;
+    font-weight: bold;
+  }
+
+  .message-reply__content {
+    color: #666;
+  }
+
+  .context-menu {
+    position: fixed;
+    background-color: white;
+    border: 1px solid #ddd;
+    border-radius: 4px;
+    box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
+    padding: 8px;
+    z-index: 1000;
+    display: none;
+  }
+
+  .message-reactions {
+    display: flex;
+    gap: 6px;
+    flex-wrap: wrap;
+    margin-top: 4px;
+    font-size: 1.1em;
+  }
+
+  .reaction-pill {
+    display: inline-block;
+    background-color: #ffe6ea;
+    color: #b30000;
+    border-radius: 12px;
+    padding: 2px 8px;
+    margin-right: 6px;
+    cursor: pointer;
+    font-size: 0.9em;
+    transition: background-color 0.2s ease;
+  }
+
+  .reaction-pill:hover {
+    background-color: #ffccd5;
+  }
+
+  .reaction-count {
+    margin-left: 4px;
+    font-weight: bold;
+  }
 </style>
 <div id="message-container"
      class="chat-messages-scroll flex-grow-1 overflow-auto p-3 bg-light d-flex flex-column">
   <div class="d-flex flex-column h-100">
     {% if messages %}
       {% for message in messages %}
-        <div class="mb-3 {% if message.user == request.user %}align-self-end{% else %}align-self-start{% endif %}">
+        <div class="mb-3 {% if message.user == request.user %}align-self-end{% else %}align-self-start{% endif %}"
+             data-message-id="{{ message.id }}"
+             oncontextmenu="showContextMenu(event, '{{ message.id }}', '{{ message.user.username }}', '{{ message.content }}'); return false;">
           <div class="{% if message.user == request.user %}bg-success-subtle{% else %}bg-white{% endif %} rounded p-2 shadow-sm">
+            {% if message.reply_to %}
+              <div class="message-reply">
+                <div class="message-reply__sender">{{ message.reply_to.user.username }}</div>
+                <div class="message-reply__content">{{ message.reply_to.content }}</div>
+              </div>
+            {% endif %}
             <div class="fw-bold small text-danger">{{ message.user.username }}</div>
             <div class="mt-1">{{ message.content }}</div>
             <div class="text-end text-muted small mt-1">{{ message.sent_at|date:"g:i A" }}</div>
+            <div class="message-reactions mt-1" id="reactions-{{ message.id }}"></div>
           </div>
         </div>
       {% endfor %}
@@ -86,3 +153,4 @@
     {% endif %}
   </div>
 </div>
+{% include "chat/components/_context_menu.html" %}
diff --git a/src/templates/chat/components/_script.html b/src/templates/chat/components/_script.html
index 680690f85..c96f6131b 100644
--- a/src/templates/chat/components/_script.html
+++ b/src/templates/chat/components/_script.html
@@ -28,8 +28,14 @@
       JSON.stringify({
         message: messageInput,
         user_id: "{{ request.user.id }}",
+        reply_to: replyToMessageId
       })
     );
+
+    // Clear reply state
+    replyToMessageId = null;
+    replyToUsername = null;
+    replyToContent = null;
   };
 
   /**
@@ -47,13 +53,33 @@
 
     // Create message container
     const messageContainer = document.createElement("div");
-    messageContainer.className = "mb-3 align-self-end";
+    messageContainer.className = `mb-3 ${data.user_id === "{{ request.user.id }}" ? "align-self-end" : "align-self-start"}`;
+    messageContainer.setAttribute("data-message-id", data.message_id);
+    messageContainer.setAttribute("oncontextmenu", `showContextMenu(event, ${data.message_id}, '${data.username}', '${data.message}'); return false;`);
 
     // Create message bubble
     const messageBubble = document.createElement("div");
-    messageBubble.className = "bg-success-subtle rounded p-2 shadow-sm";
+    messageBubble.className = `${data.user_id === "{{ request.user.id }}" ? "bg-success-subtle" : "bg-white"} rounded p-2 shadow-sm`;
     messageBubble.style.maxWidth = "80%";
 
+    // Add reply preview if this is a reply
+    if (data.reply_to) {
+      const replyPreview = document.createElement("div");
+      replyPreview.className = "message-reply";
+
+      const replySender = document.createElement("div");
+      replySender.className = "message-reply__sender";
+      replySender.textContent = data.reply_to_username;
+
+      const replyContent = document.createElement("div");
+      replyContent.className = "message-reply__content";
+      replyContent.textContent = data.reply_to_content;
+
+      replyPreview.appendChild(replySender);
+      replyPreview.appendChild(replyContent);
+      messageBubble.appendChild(replyPreview);
+    }
+
     // Create message sender
     const sender = document.createElement("div");
     sender.className = "fw-bold small text-danger";
@@ -112,13 +138,9 @@
 
       // Show collapse button only in floating mode
       if (chatContainer.classList.contains("floating-chat")) {
-        if (collapseButton) {
-          collapseButton.classList.remove("d-none");
-        }
+        collapseButton.classList.remove("d-none");
       } else {
-        if (collapseButton) {
-          collapseButton.classList.add("d-none");
-        }
+        collapseButton.classList.add("d-none");
         chatContainer.classList.remove("collapsed-chat");
       }
     });
diff --git a/src/templates/chat/live-chat-list.html b/src/templates/chat/live-chat-list.html
new file mode 100644
index 000000000..66c576358
--- /dev/null
+++ b/src/templates/chat/live-chat-list.html
@@ -0,0 +1,58 @@
+{% extends "base.html" %}
+
+{% block content %}
+  <div class="container">
+    <h1>Live Chats</h1>
+    <div class="search-container mb-3">
+      <input type="text"
+             id="search-input"
+             class="form-control"
+             placeholder="Search public chats..."
+             value="{{ request.GET.q|default:'' }}" />
+    </div>
+    <ul class="list-group">
+      {% for chat in chats %}
+        <li class="list-group-item">
+          <a href="{% url 'chat-page' chat.id %}">{{ chat.name }}</a>
+        </li>
+      {% endfor %}
+    </ul>
+  </div>
+  <script>
+    document.addEventListener('DOMContentLoaded', function() {
+      const searchInput = document.getElementById('search-input');
+
+      const debounce = (func, delay) => {
+        let timeout;
+        return function(...args) {
+          clearTimeout(timeout);
+          timeout = setTimeout(() => func.apply(this, args), delay);
+        };
+      };
+
+      const handleSearchInput = debounce(function() {
+        const searchQuery = this.value.trim();
+        const currentUrl = new URL(window.location.href);
+
+        if (searchQuery) {
+          currentUrl.searchParams.set('q', searchQuery);
+        } else {
+          currentUrl.searchParams.delete('q');
+        }
+
+        window.history.pushState({}, '', currentUrl);
+
+        // Filter the chat list
+        const chatItems = document.querySelectorAll('.list-group-item');
+        chatItems.forEach(item => {
+          const chatName = item.textContent.toLowerCase();
+          if (chatName.includes(searchQuery.toLowerCase())) {
+            item.style.display = '';
+          } else {
+            item.style.display = 'none';
+          }
+        });
+      });
+    });
+  </script>
+{% endblock content %}


Reviews:
user: login: quincyleung
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-18T21:51:53Z
user: login: pmdepaulo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T02:17:05Z
user: login: quincyleung
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T02:32:03Z
user: login: quincyleung
body: Tested it locally and it works smoothly, great work! Seems like you have a solid plan for how to move forward as well 💯  I did notice that the add reactions option on the menu has a different background color than the rest, but that's super minor and could be addressed in a future PR!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T19:47:05Z
user: login: abargher
body: Looks great, this is significant work!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T19:46:51Z

Review comments:
diff_hunk: @@ -0,0 +1,86 @@
+<!-- Context Menu -->
+<div id="contextMenu" class="context-menu">
+  <div class="small text-muted mb-1" id="replyPreview"></div>
+  <button class="btn btn-sm btn-light w-100" onclick="deleteMessage()">Pinning Messages</button>
+  <button class="btn btn-sm btn-light w-100" onclick="replyToMessage()">Reply</button>
+  <button class="btn btn-sm btn-light w-100" onclick="deleteMessage()">Edit Message</button>
+  <button class="btn btn-sm btnlight w-100" onclick="reactToMessage()">Add Reaction</button>
+  <button class="btn btn-sm btn-danger w-100" onclick="deleteMessage()">Delete</button>
+</div>
+<script>
+  let replyToMessageId = null;
+  let replyToUsername = null;
+  let replyToContent = null;
+
+  function showContextMenu(event, messageId, username, content) {
+    const contextMenu = document.getElementById('contextMenu');
+    const replyPreview = document.getElementById('replyPreview');
+
+    // Store reply information
+    replyToMessageId = messageId;
+    replyToUsername = username;
+    replyToContent = content;
+
+    // Update preview
+    replyPreview.textContent = `Replying to ${username}: ${content}`;
+
+    // Position and show menu
+    contextMenu.style.left = event.pageX + 'px';
+    contextMenu.style.top = event.pageY + 'px';
+    contextMenu.style.display = 'block';
+
+    // Hide menu when clicking elsewhere
+    document.addEventListener('click', hideContextMenu);
+  }
+
+  function hideContextMenu() {
+    const contextMenu = document.getElementById('contextMenu');
+    contextMenu.style.display = 'none';
+    document.removeEventListener('click', hideContextMenu);
+  }
+
+  function replyToMessage() {
+    const input = document.getElementById('id_message_send_input');
+    input.focus();
+    hideContextMenu();
+  }
+
+  /**
+   * Deletes a message on the frontend.
+   */
+  function deleteMessage() {
+    const messageId = replyToMessageId;
+    fetch(`/chat/message/${messageId}/delete`, {
+      method: 'GET',
+    }).then(response => {
+      if (response.ok) {
+        const messageElement = document.querySelector(`[data-message-id="${messageId}"]`);
+        if (messageElement) {
+          messageElement.remove();
+        }
+      } else {
+        console.error('Failed to delete message');
+      }
+    });
+  }
+
+  /**
+   * Reacts to a message on the frontend.
+   */
+  function reactToMessage(content = "❤️") {
+    const messageId = replyToMessageId;
+    fetch('/chat/message/<int:message_id>/react', {
path: src/templates/chat/components/_context_menu.html
user: login: quincyleung
body: i believe the issue might be related to this line - could try '/chat/message/${messageId}/react' instead
created_at: 2025-05-18T21:51:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,86 @@
+<!-- Context Menu -->
+<div id="contextMenu" class="context-menu">
+  <div class="small text-muted mb-1" id="replyPreview"></div>
+  <button class="btn btn-sm btn-light w-100" onclick="deleteMessage()">Pinning Messages</button>
+  <button class="btn btn-sm btn-light w-100" onclick="replyToMessage()">Reply</button>
+  <button class="btn btn-sm btn-light w-100" onclick="deleteMessage()">Edit Message</button>
+  <button class="btn btn-sm btnlight w-100" onclick="reactToMessage()">Add Reaction</button>
+  <button class="btn btn-sm btn-danger w-100" onclick="deleteMessage()">Delete</button>
+</div>
+<script>
+  let replyToMessageId = null;
+  let replyToUsername = null;
+  let replyToContent = null;
+
+  function showContextMenu(event, messageId, username, content) {
+    const contextMenu = document.getElementById('contextMenu');
+    const replyPreview = document.getElementById('replyPreview');
+
+    // Store reply information
+    replyToMessageId = messageId;
+    replyToUsername = username;
+    replyToContent = content;
+
+    // Update preview
+    replyPreview.textContent = `Replying to ${username}: ${content}`;
+
+    // Position and show menu
+    contextMenu.style.left = event.pageX + 'px';
+    contextMenu.style.top = event.pageY + 'px';
+    contextMenu.style.display = 'block';
+
+    // Hide menu when clicking elsewhere
+    document.addEventListener('click', hideContextMenu);
+  }
+
+  function hideContextMenu() {
+    const contextMenu = document.getElementById('contextMenu');
+    contextMenu.style.display = 'none';
+    document.removeEventListener('click', hideContextMenu);
+  }
+
+  function replyToMessage() {
+    const input = document.getElementById('id_message_send_input');
+    input.focus();
+    hideContextMenu();
+  }
+
+  /**
+   * Deletes a message on the frontend.
+   */
+  function deleteMessage() {
+    const messageId = replyToMessageId;
+    fetch(`/chat/message/${messageId}/delete`, {
+      method: 'GET',
+    }).then(response => {
+      if (response.ok) {
+        const messageElement = document.querySelector(`[data-message-id="${messageId}"]`);
+        if (messageElement) {
+          messageElement.remove();
+        }
+      } else {
+        console.error('Failed to delete message');
+      }
+    });
+  }
+
+  /**
+   * Reacts to a message on the frontend.
+   */
+  function reactToMessage(content = "❤️") {
+    const messageId = replyToMessageId;
+    fetch('/chat/message/<int:message_id>/react', {
path: src/templates/chat/components/_context_menu.html
user: login: pmdepaulo
body: just fixed, thank you! requesting another review if possible
created_at: 2025-05-19T02:17:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -69,7 +69,7 @@
    */
   function reactToMessage(content = "❤️") {
     const messageId = replyToMessageId;
-    fetch('/chat/message/<int:message_id>/react', {
+    fetch(`/chat/message/${messageId}/delete`, {
path: src/templates/chat/components/_context_menu.html
user: login: quincyleung
body: I think you meant to change it to `react` and not `delete`?
created_at: 2025-05-19T02:31:53Z
author_association: CONTRIBUTOR

PR comments:
user: login: pmdepaulo
created_at: 2025-05-14T16:36:15Z
body: This PR also branches off of live-chat/creating-componenet-for-drop-down-menu-when-right-click-on-message (PR #958).
Contributes to Issue #594 
author_association: CONTRIBUTOR
user: login: quincyleung
created_at: 2025-05-18T21:48:56Z
body: I'm trying to test it locally but it doesn't seem to work for me - looked in my log and was getting this error: 
```
Not Found: /chat/message/<int:message_id>/react
HTTP POST /chat/message/%3Cint:message_id%3E/react 404
```
Perhaps there's a mistake with the URL?
author_association: CONTRIBUTOR
user: login: pmdepaulo
created_at: 2025-05-19T18:01:17Z
body: Finished basic chat reaction with a <3 emoji! Planning on further implementing with an emoji picker, works to add and remove reactions to the message UI.

https://github.com/user-attachments/assets/25f67cae-a25e-4234-8740-f9705adc9b41


author_association: CONTRIBUTOR
user: login: abargher
created_at: 2025-06-01T17:56:47Z
body: Issue Score: Excellent

Comments:
Great job! Nice work documenting the process as well, this is a large change.

<!-- {"score": "excellent", "minor": false} -->
author_association: CONTRIBUTOR