diff --git a/migration/migrator/data/course_tables.sql b/migration/migrator/data/course_tables.sql
index a45fa410ea3..44d6aa68f98 100644
--- a/migration/migrator/data/course_tables.sql
+++ b/migration/migrator/data/course_tables.sql
@@ -898,31 +898,10 @@ CREATE TABLE public.forum_attachments (
post_id integer NOT NULL,
file_name character varying NOT NULL,
version_added integer DEFAULT 1 NOT NULL,
- version_deleted integer DEFAULT 0 NOT NULL,
- id integer NOT NULL
+ version_deleted integer DEFAULT 0 NOT NULL
);
---
--- Name: forum_attachments_id_seq; Type: SEQUENCE; Schema: public; Owner: -
---
-
-CREATE SEQUENCE public.forum_attachments_id_seq
- AS integer
- START WITH 1
- INCREMENT BY 1
- NO MINVALUE
- NO MAXVALUE
- CACHE 1;
-
-
---
--- Name: forum_attachments_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
---
-
-ALTER SEQUENCE public.forum_attachments_id_seq OWNED BY public.forum_attachments.id;
-
-
--
-- Name: forum_posts_history; Type: TABLE; Schema: public; Owner: -
--
@@ -1979,13 +1958,6 @@ ALTER TABLE ONLY public.course_materials_access ALTER COLUMN id SET DEFAULT next
ALTER TABLE ONLY public.course_materials_sections ALTER COLUMN id SET DEFAULT nextval('public.course_materials_sections_id_seq'::regclass);
---
--- Name: forum_attachments id; Type: DEFAULT; Schema: public; Owner: -
---
-
-ALTER TABLE ONLY public.forum_attachments ALTER COLUMN id SET DEFAULT nextval('public.forum_attachments_id_seq'::regclass);
-
-
--
-- Name: grade_inquiries id; Type: DEFAULT; Schema: public; Owner: -
--
@@ -2192,14 +2164,6 @@ ALTER TABLE ONLY public.electronic_gradeable
ADD CONSTRAINT electronic_gradeable_g_id_pkey PRIMARY KEY (g_id);
---
--- Name: forum_attachments forum_attachments_pkey; Type: CONSTRAINT; Schema: public; Owner: -
---
-
-ALTER TABLE ONLY public.forum_attachments
- ADD CONSTRAINT forum_attachments_pkey PRIMARY KEY (id);
-
-
--
-- Name: forum_upducks forum_upducks_user_id_post_id_key; Type: CONSTRAINT; Schema: public; Owner: -
--
diff --git a/migration/migrator/migrations/course/20240209021641_lecture_chat.py b/migration/migrator/migrations/course/20240209021641_lecture_chat.py
new file mode 100644
index 00000000000..e7ee99f1b71
--- /dev/null
+++ b/migration/migrator/migrations/course/20240209021641_lecture_chat.py
@@ -0,0 +1,70 @@
+"""Migration for a given Submitty course database."""
+
+import json
+from pathlib import Path
+
+def up(config, database, semester, course):
+ """
+ Run up migration.
+ :param config: Object holding configuration details about Submitty
+ :type config: migrator.config.Config
+ :param database: Object for interacting with given database for environment
+ :type database: migrator.db.Database
+ :param semester: Semester of the course being migrated
+ :type semester: str
+ :param course: Code of course being migrated
+ :type course: str
+ """
+
+ database.execute(
+ """
+ CREATE TABLE IF NOT EXISTS chatrooms (
+ id SERIAL PRIMARY KEY,
+ host_id character varying NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
+ host_name character varying,
+ title text NOT NULL,
+ description text,
+ is_active BOOLEAN DEFAULT false NOT NULL,
+ allow_anon BOOLEAN DEFAULT true NOT NULL
+ );
+
+ CREATE TABLE IF NOT EXISTS chatroom_messages (
+ id SERIAL PRIMARY KEY,
+ chatroom_id integer NOT NULL REFERENCES chatrooms(id) ON DELETE CASCADE,
+ user_id character varying NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
+ display_name character varying,
+ is_pinned boolean,
+ who_pinned character varying,
+ role character varying,
+ content text NOT NULL,
+ timestamp timestamp(0) with time zone NOT NULL
+ );
+ """
+ )
+ course_dir = Path(config.submitty['submitty_data_dir'], 'courses', semester, course)
+ # add boolean to course config
+ config_file = Path(course_dir, 'config', 'config.json')
+ if config_file.is_file():
+ with open(config_file, 'r') as in_file:
+ j = json.load(in_file)
+ j['course_details']['chat_enabled'] = False
+
+ with open(config_file, 'w') as out_file:
+ json.dump(j, out_file, indent=4)
+
+def down(config, database, semester, course):
+ """
+ Run down migration (rollback).
+ :param config: Object holding configuration details about Submitty
+ :type config: migrator.config.Config
+ :param database: Object for interacting with given database for environment
+ :type database: migrator.db.Database
+ :param semester: Semester of the course being migrated
+ :type semester: str
+ :param course: Code of course being migrated
+ :type course: str
+ """
+ database.execute("DROP TABLE IF EXISTS chatrooms cascade")
+ database.execute("DROP TABLE IF EXISTS chatroom_messages")
+
+ pass
diff --git a/site/app/controllers/ChatroomController.php b/site/app/controllers/ChatroomController.php
new file mode 100644
index 00000000000..389a820252e
--- /dev/null
+++ b/site/app/controllers/ChatroomController.php
@@ -0,0 +1,230 @@
+core->getCourseEntityManager()->getRepository(Chatroom::class);
+ $chatrooms = $repo->findBy([], ['id' => 'ASC']);
+
+ return new WebResponse(
+ 'Chatroom',
+ 'showAllChatrooms',
+ $chatrooms
+ );
+ }
+
+ /**
+ * @Route("/courses/{_semester}/{_course}/chat/new", methods={"POST"})
+ * @AccessControl(role="INSTRUCTOR")
+ */
+ public function addChatroom(): RedirectResponse {
+ $em = $this->core->getCourseEntityManager();
+
+ $hostId = $this->core->getUser()->getId();
+ $hostName = $this->core->getUser()->getDisplayFullName();
+ $chatroom = new Chatroom($hostId, $hostName, $_POST['title'], $_POST['description']);
+ if (!isset($_POST['allow-anon'])) {
+ $chatroom->setAllowAnon(false);
+ }
+
+ $em->persist($chatroom);
+ $em->flush();
+
+ $this->core->addSuccessMessage("Chatroom successfully added");
+ return new RedirectResponse($this->core->buildCourseUrl(['chat']));
+ }
+
+ /**
+ * @Route("/courses/{_semester}/{_course}/chat/{chatroom_id}", methods={"GET"})
+ * @param string $chatroom_id
+ * @return RedirectResponse|WebResponse
+ */
+ public function getChatroom(string $chatroom_id): WebResponse|RedirectResponse {
+ if (!is_numeric($chatroom_id)) {
+ $this->core->addErrorMessage("Invalid Chatroom ID");
+ return new RedirectResponse($this->core->buildCourseUrl(['chat']));
+ }
+
+ $repo = $this->core->getCourseEntityManager()->getRepository(Chatroom::class);
+ $chatroom = $repo->find($chatroom_id);
+
+ if ($chatroom == null) {
+ $this->core->addErrorMessage("chatroom not found");
+ return new RedirectResponse($this->core->buildCourseUrl(['chat']));
+ }
+
+ return new WebResponse(
+ 'Chatroom',
+ 'showChatroom',
+ $chatroom,
+ );
+ }
+
+ /**
+ * @Route("/courses/{_semester}/{_course}/chat/{chatroom_id}/anonymous", methods={"GET"})
+ * @param string $chatroom_id
+ * @return RedirectResponse|WebResponse
+ */
+ public function getChatroomAnon(string $chatroom_id): WebResponse|RedirectResponse {
+ if (!is_numeric($chatroom_id)) {
+ $this->core->addErrorMessage("Invalid Chatroom ID");
+ return new RedirectResponse($this->core->buildCourseUrl(['chat']));
+ }
+
+ $repo = $this->core->getCourseEntityManager()->getRepository(Chatroom::class);
+ $chatroom = $repo->find($chatroom_id);
+
+ return new WebResponse(
+ 'Chatroom',
+ 'showChatroom',
+ $chatroom,
+ true,
+ );
+ }
+
+ /**
+ * @Route("/courses/{_semester}/{_course}/chat/delete", methods={"POST"})
+ * @AccessControl(role="INSTRUCTOR")
+ */
+ public function deleteChatroom(): JsonResponse {
+ $chatroom_id = intval($_POST['chatroom_id'] ?? -1);
+ $em = $this->core->getCourseEntityManager();
+
+ $repo = $em->getRepository(Chatroom::class);
+
+ $chatroom = $repo->find($chatroom_id);
+ if ($chatroom === null) {
+ return JsonResponse::getFailResponse('Invalid Chatroom ID');
+ }
+ foreach ($chatroom->getMessages() as $message) {
+ $em->remove($message);
+ }
+ $em->remove($chatroom);
+ $em->flush();
+ return JsonResponse::getSuccessResponse();
+ }
+
+ /**
+ * @Route("/courses/{_semester}/{_course}/chat/{chatroom_id}/edit", methods={"POST"})
+ * @AccessControl(role="INSTRUCTOR")
+ */
+ public function editChatroom(string $chatroom_id): RedirectResponse {
+ $em = $this->core->getCourseEntityManager();
+ $chatroom = $em->getRepository(Chatroom::class)->find($chatroom_id);
+
+ if (!$chatroom) {
+ $this->core->addErrorMessage("Chatroom not found");
+ return new RedirectResponse($this->core->buildCourseUrl(['chat']));
+ }
+
+ if (isset($_POST['title'])) {
+ $chatroom->setTitle($_POST['title']);
+ }
+ if (isset($_POST['description'])) {
+ $chatroom->setDescription($_POST['description']);
+ }
+ if (isset($_POST['allow-anon'])) {
+ $chatroom->setAllowAnon(true);
+ }
+ else {
+ $chatroom->setAllowAnon(false);
+ }
+
+ $em->flush();
+
+ $this->core->addSuccessMessage("Chatroom successfully updated");
+ return new RedirectResponse($this->core->buildCourseUrl(['chat']));
+ }
+
+ /**
+ * @Route("/courses/{_semester}/{_course}/chat/{chatroom_id}/toggleOnOff", methods={"POST"})
+ * @AccessControl(role="INSTRUCTOR")
+ */
+ public function toggleChatroomOnOff(string $chatroom_id): RedirectResponse {
+ $em = $this->core->getCourseEntityManager();
+ $chatroom = $em->getRepository(Chatroom::class)->find($chatroom_id);
+
+ if ($chatroom === null) {
+ $this->core->addErrorMessage("Chatroom not found");
+ return new RedirectResponse($this->core->buildCourseUrl(['chat']));
+ }
+
+ $chatroom->toggle_on_off();
+
+ $em->flush();
+
+ return new RedirectResponse($this->core->buildCourseUrl(['chat']));
+ }
+
+ /**
+ * @Route("/courses/{_semester}/{_course}/chat/{chatroom_id}/messages", methods={"GET"})
+ */
+ public function fetchMessages(string $chatroom_id): JsonResponse {
+ $em = $this->core->getCourseEntityManager();
+ $messages = $em->getRepository(Message::class)->findBy(['chatroom' => $chatroom_id], ['timestamp' => 'ASC']);
+
+ $formattedMessages = array_map(function ($message) {
+ return [
+ 'id' => $message->getId(),
+ 'content' => $message->getContent(),
+ 'timestamp' => $message->getTimestamp()->format('Y-m-d H:i:s'),
+ 'user_id' => $message->getUserId(),
+ 'display_name' => $message->getDisplayName(),
+ 'role' => $message->getRole(),
+ 'is_pinned' => $message->isPinned(),
+ 'pinned_by' => $message->getWhoPinned()
+ ];
+ }, $messages);
+
+ return JsonResponse::getSuccessResponse($formattedMessages);
+ }
+
+ /**
+ * @Route("/courses/{_semester}/{_course}/chat/{chatroom_id}/send", methods={"POST"})
+ */
+ public function addMessage(string $chatroom_id): JsonResponse {
+ $em = $this->core->getCourseEntityManager();
+ $content = $_POST['content'];
+ $userId = $_POST['user_id'];
+ $displayName = $_POST['display_name'] ?? '';
+ $role = $_POST['role'] ?? 'student';
+ $chatroom = $em->getRepository(Chatroom::class)->find($chatroom_id);
+
+ $message = new Message($userId, $displayName, $role, $content, $chatroom);
+
+ $em->persist($message);
+ $em->flush();
+
+ return JsonResponse::getSuccessResponse($message);
+ }
+
+ /**
+ * @Route("/courses/{_semester}/{_course}/chat/{chatroom_id}/{message_id}/pin", methods={"POST"})
+ */
+ public function pinMessage(string $chatroom_id, string $message_id): JsonResponse {
+ $em = $this->core->getCourseEntityManager();
+ $chatroom = $em->getRepository(Chatroom::class)->find($chatroom_id);
+ $message = $chatroom->getRepository(Message::class)->find($message_id);
+
+ $em->persist($message);
+ $em->flush();
+
+ return JsonResponse::getSuccessResponse($message);
+ }
+}
diff --git a/site/app/controllers/GlobalController.php b/site/app/controllers/GlobalController.php
index 5b5755d5172..d05776bd5e7 100644
--- a/site/app/controllers/GlobalController.php
+++ b/site/app/controllers/GlobalController.php
@@ -182,6 +182,15 @@ public function prep_course_sidebar(&$sidebar_buttons, $unread_notifications_cou
]);
}
+ if ($this->core->getConfig()->isChatEnabled()) {
+ $sidebar_buttons[] = new NavButton($this->core, [
+ "href" => $this->core->buildCourseUrl(['chat']),
+ "title" => "Live Lecture Chat",
+ "id" => "nav-sidebar-chat",
+ "icon" => "fa-regular fa-keyboard"
+ ]);
+ }
+
$course_path = $this->core->getConfig()->getCoursePath();
$course_materials_path = $course_path . "/uploads/course_materials";
$empty = FileUtils::isEmptyDir($course_materials_path);
diff --git a/site/app/controllers/admin/ConfigurationController.php b/site/app/controllers/admin/ConfigurationController.php
index 639a9d146c8..28bffeba5e8 100644
--- a/site/app/controllers/admin/ConfigurationController.php
+++ b/site/app/controllers/admin/ConfigurationController.php
@@ -54,7 +54,8 @@ public function viewConfiguration(): MultiResponse {
'seek_message_enabled' => $this->core->getConfig()->isSeekMessageEnabled(),
'seek_message_instructions' => $this->core->getConfig()->getSeekMessageInstructions(),
'queue_announcement_message' => $this->core->getConfig()->getQueueAnnouncementMessage(),
- 'polls_enabled' => $this->core->getConfig()->isPollsEnabled()
+ 'polls_enabled' => $this->core->getConfig()->isPollsEnabled(),
+ 'chat_enabled' => $this->core->getCOnfig()->isChatEnabled()
];
$seating_options = $this->getGradeableSeatingOptions();
$admin_in_course = false;
@@ -149,7 +150,8 @@ public function updateConfiguration(): MultiResponse {
'seating_only_for_instructor',
'queue_enabled',
'seek_message_enabled',
- 'polls_enabled'
+ 'polls_enabled',
+ 'chat_enabled'
]
)
) {
diff --git a/site/app/entities/chat/Chatroom.php b/site/app/entities/chat/Chatroom.php
new file mode 100644
index 00000000000..03dfb1d1db2
--- /dev/null
+++ b/site/app/entities/chat/Chatroom.php
@@ -0,0 +1,114 @@
+
+ */
+ #[ORM\OneToMany(mappedBy: "chatroom", targetEntity: Message::class)]
+ private Collection $messages;
+
+ public function __construct($hostId, $hostName, $title, $description) {
+ $this->setHostId($hostId);
+ $this->setHostName($hostName);
+ $this->setTitle($title);
+ $this->setDescription($description);
+ $this->messages = new ArrayCollection();
+ $this->is_active = false;
+ $this->allow_anon = true;
+ }
+
+ public function getId(): int {
+ return $this->id;
+ }
+
+ public function setHostId($hostId): void {
+ $this->host_id = $hostId;
+ }
+
+ public function getHostId(): string {
+ return $this->host_id;
+ }
+
+ public function setHostName($hostName): void {
+ $this->host_name = $hostName;
+ }
+
+ public function getHostName(): string {
+ return $this->host_name;
+ }
+
+ public function getTitle(): string {
+ return $this->title;
+ }
+
+ public function setTitle(string $title): void {
+ $this->title = $title;
+ }
+
+ public function getDescription(): string {
+ return $this->description;
+ }
+
+ public function setDescription($description): void {
+ $this->description = $description;
+ }
+
+ public function toggle_on_off(): void {
+ $this->is_active = !$this->is_active;
+ }
+
+ public function isActive(): bool {
+ return $this->is_active;
+ }
+
+ public function isAllowAnon(): bool {
+ return $this->allow_anon;
+ }
+
+ public function setAllowAnon($allow_anon): void {
+ $this->allow_anon = $allow_anon;
+ }
+
+ public function getMessages(): Collection {
+ return $this->messages;
+ }
+
+ public function addMessage(Message $message): void {
+ $this->messages->add($message);
+ }
+}
diff --git a/site/app/entities/chat/Message.php b/site/app/entities/chat/Message.php
new file mode 100644
index 00000000000..dd9cd7a555d
--- /dev/null
+++ b/site/app/entities/chat/Message.php
@@ -0,0 +1,122 @@
+setUserId($userId);
+ $this->setDisplayName($displayName);
+ $this->setRole($role);
+ $this->setTimestamp(new \DateTime("now"));
+ $this->setContent($text);
+ $this->setChatroom($chatroom);
+ $this->setIsPinned(false);
+ $this->setWhoPinned('');
+ }
+
+ public function getId(): int {
+ return $this->id;
+ }
+
+ public function isPinned(): bool {
+ return $this->is_pinned;
+ }
+
+ public function setIsPinned(bool $is_pinned): void {
+ $this->is_pinned = $is_pinned;
+ }
+
+ public function getWhoPinned(): string {
+ return $this->who_pinned;
+ }
+
+ public function setWhoPinned(string $who_pinned): void {
+ $this->who_pinned = $who_pinned;
+ }
+
+ public function getUserId(): string {
+ return $this->user_id;
+ }
+
+ public function setUserId($userId): void {
+ $this->user_id = $userId;
+ }
+
+ public function getDisplayName(): string {
+ return $this->display_name;
+ }
+
+ public function setDisplayName($displayName): void {
+ $this->display_name = $displayName;
+ }
+
+ public function getRole(): string {
+ return $this->role;
+ }
+
+ public function setRole($role): string {
+ return $this->role = $role;
+ }
+
+ public function getContent(): string {
+ return $this->content;
+ }
+
+ public function setContent(string $text): void {
+ $this->content = $text;
+ }
+
+ public function getTimestamp(): DateTime {
+ return $this->timestamp;
+ }
+
+ public function setTimestamp(DateTime $timestamp): void {
+ $this->timestamp = $timestamp;
+ }
+
+ public function getChatroom(): Chatroom {
+ return $this->chatroom;
+ }
+
+ public function setChatroom(Chatroom $chatroom): void {
+ $this->chatroom = $chatroom;
+ }
+}
diff --git a/site/app/models/Config.php b/site/app/models/Config.php
index 43bee9bf307..d600c00d96b 100644
--- a/site/app/models/Config.php
+++ b/site/app/models/Config.php
@@ -68,6 +68,7 @@
* @method bool isQueueEnabled()
* @method bool isSeekMessageEnabled()
* @method bool isPollsEnabled()
+ * @method bool isChatEnabled()
* @method void setTerm(string $term)
* @method void setCourse(string $course)
* @method void setCoursePath(string $course_path)
@@ -328,7 +329,9 @@ class Config extends AbstractModel {
/** @prop
* @var bool */
protected $polls_enabled;
-
+ /** @prop
+ * @var bool */
+ protected $chat_enabled;
/** @prop-read
* @var array */
@@ -575,7 +578,8 @@ public function loadCourseJson($semester, $course, $course_json_path) {
'zero_rubric_grades', 'upload_message', 'display_rainbow_grades_summary',
'display_custom_message', 'room_seating_gradeable_id', 'course_email', 'vcs_base_url', 'vcs_type',
'private_repository', 'forum_enabled', 'forum_create_thread_message', 'seating_only_for_instructor',
- 'grade_inquiry_message', 'auto_rainbow_grades', 'queue_enabled', 'queue_message', 'polls_enabled', 'queue_announcement_message', 'seek_message_enabled', 'seek_message_instructions'
+ 'grade_inquiry_message', 'auto_rainbow_grades', 'queue_enabled', 'queue_message', 'polls_enabled',
+ 'queue_announcement_message', 'seek_message_enabled', 'seek_message_instructions', 'chat_enabled'
];
$this->setConfigValues($this->course_json, 'course_details', $array);
@@ -605,6 +609,7 @@ public function loadCourseJson($semester, $course, $course_json_path) {
'queue_enabled',
'polls_enabled',
'seek_message_enabled',
+ 'chat_enabled',
];
foreach ($array as $key) {
$this->$key = (bool) $this->$key;
diff --git a/site/app/repositories/chat/ChatroomRepository.php b/site/app/repositories/chat/ChatroomRepository.php
new file mode 100644
index 00000000000..1b17e118ffc
--- /dev/null
+++ b/site/app/repositories/chat/ChatroomRepository.php
@@ -0,0 +1,33 @@
+createQueryBuilder('c')
+ ->where('c.host_id = :hostId')
+ ->setParameter('hostId', $hostId)
+ ->getQuery()
+ ->getResult();
+ }
+
+ public function findAllActiveChatrooms() {
+ return $this->createQueryBuilder('c')
+ ->where('c.isActive = :isActive')
+ ->setParameter('isActive', true)
+ ->getQuery()
+ ->getResult();
+ }
+
+ public function findAllInactiveChatrooms() {
+ return $this->createQueryBuilder('c')
+ ->where('c.isActive = :isActive')
+ ->setParameter('isActive', false)
+ ->getQuery()
+ ->getResult();
+ }
+}
diff --git a/site/app/templates/admin/Configuration.twig b/site/app/templates/admin/Configuration.twig
index 4c91677daad..9ec608f8656 100644
--- a/site/app/templates/admin/Configuration.twig
+++ b/site/app/templates/admin/Configuration.twig
@@ -90,6 +90,17 @@
Choose whether to enable online polling for this course.
+
+
Live Lecture Chat
+
+
+
+
+
Submissions
diff --git a/site/app/templates/chat/AllChatroomsPage.twig b/site/app/templates/chat/AllChatroomsPage.twig
new file mode 100644
index 00000000000..e058c6177c5
--- /dev/null
+++ b/site/app/templates/chat/AllChatroomsPage.twig
@@ -0,0 +1,119 @@
+
+
Live Lecture Chat
+ {% if user_admin %}
+
New Chatroom
+ {% endif %}
+
+
+
Chatrooms
+
+ {% if user_admin %}
+
+
+
+
+
+
+
+
+ |
+ |
+ Name |
+ Description |
+ |
+ |
+
+
+
+ {% for chatroom in chatrooms %}
+
+ |
+
+ |
+
+ {% if not chatroom.isActive() %}
+
+ {% endif %}
+ |
+
+
+ {{ chatroom.getTitle() | length > 30 ? chatroom.getTitle() | slice(0, 30) ~ '...' : chatroom.getTitle() }}
+
+ |
+
+
+ {{ chatroom.getDescription() | length > 45 ? chatroom.getDescription() | slice(0, 45) ~ '...' : chatroom.getDescription() }}
+
+ |
+
+
+ {% if not chatroom.isActive() %}
+
+ {% else %}
+
+ {% endif %}
+ |
+
+ join
+ {% if chatroom.isAllowAnon() %}
+ or
+ Join Anonymously
+ {% endif %}
+ |
+
+ {% endfor %}
+
+ {% else %}
+
+
+
+
+
+
+ | Name |
+ Host |
+ Description |
+ |
+
+
+
+ {% for chatroom in chatrooms %}
+ {% if chatroom.isActive() %}
+
+ |
+
+ {{ chatroom.getTitle() | length > 30 ? chatroom.getTitle() | slice(0, 30) ~ '...' : chatroom.getTitle() }}
+
+ |
+
+ {{ chatroom.getHostName() }}
+ |
+
+
+ {{ chatroom.getDescription() | length > 45 ? chatroom.getDescription() | slice(0, 45) ~ '...' : chatroom.getDescription() }}
+
+ |
+
+ Join
+ {% if chatroom.isAllowAnon() %}
+ or
+ Join Anonymously
+ {% endif %}
+ |
+
+ {% endif %}
+ {% endfor %}
+
+ {% endif %}
+
+
+
+{% include('chat/CreateChatroomForm.twig') %}
+{% include('chat/EditChatroomForm.twig') %}
diff --git a/site/app/templates/chat/Chatroom.twig b/site/app/templates/chat/Chatroom.twig
new file mode 100644
index 00000000000..79fe5711c63
--- /dev/null
+++ b/site/app/templates/chat/Chatroom.twig
@@ -0,0 +1,26 @@
+
+
+
diff --git a/site/app/templates/chat/CreateChatroomForm.twig b/site/app/templates/chat/CreateChatroomForm.twig
new file mode 100644
index 00000000000..f1c1711c233
--- /dev/null
+++ b/site/app/templates/chat/CreateChatroomForm.twig
@@ -0,0 +1,32 @@
+{% extends 'generic/Popup.twig' %}
+{% block popup_id %}create-chatroom-form{% endblock %}
+{% block title %}Create Chatroom{% endblock %}
+{% block body %}
+
+
+{% endblock %}
+{% block buttons %}
+ {{ block('close_button') }}
+
+{% endblock %}
diff --git a/site/app/templates/chat/EditChatroomForm.twig b/site/app/templates/chat/EditChatroomForm.twig
new file mode 100644
index 00000000000..bd84485de9c
--- /dev/null
+++ b/site/app/templates/chat/EditChatroomForm.twig
@@ -0,0 +1,32 @@
+{% extends 'generic/Popup.twig' %}
+{% block popup_id %}edit-chatroom-form{% endblock %}
+{% block title %}Edit Chatroom{% endblock %}
+{% block body %}
+
+
+{% endblock %}
+{% block buttons %}
+ {{ block('close_button') }}
+
+{% endblock %}
diff --git a/site/app/views/ChatroomView.php b/site/app/views/ChatroomView.php
new file mode 100644
index 00000000000..665acce4623
--- /dev/null
+++ b/site/app/views/ChatroomView.php
@@ -0,0 +1,74 @@
+core->getOutput()->addBreadcrumb("Live Lecture Chat", $this->core->buildCourseUrl(['chat']));
+ $this->core->getOutput()->addInternalCss('chatroom.css');
+ $this->core->getOutput()->addInternalJs('chatroom.js');
+ $this->core->getOutput()->addInternalJs('websocket.js');
+ }
+
+ public function showChatPageInstructor(array $chatrooms) {
+ return $this->core->getOutput()->renderTwigTemplate("chat/ChatPageIns.twig", [
+ 'csrf_token' => $this->core->getCsrfToken(),
+ 'base_url' => $this->core->buildCourseUrl() . '/chat',
+ 'semester' => $this->core->getConfig()->getTerm(),
+ 'course' => $this->core->getConfig()->getCourse(),
+ 'chatrooms' => $chatrooms
+ ]);
+ }
+
+ public function showChatPageStudent(array $chatrooms) {
+ return $this->core->getOutput()->renderTwigTemplate("chat/ChatPageStu.twig", [
+ 'csrf_token' => $this->core->getCsrfToken(),
+ 'base_url' => $this->core->buildCourseUrl() . '/chat',
+ 'semester' => $this->core->getConfig()->getTerm(),
+ 'course' => $this->core->getConfig()->getCourse(),
+ 'chatrooms' => $chatrooms
+ ]);
+ }
+
+ public function showAllChatrooms(array $chatrooms) {
+ return $this->core->getOutput()->renderTwigTemplate("chat/AllChatroomsPage.twig", [
+ 'csrf_token' => $this->core->getCsrfToken(),
+ 'base_url' => $this->core->buildCourseUrl() . '/chat',
+ 'semester' => $this->core->getConfig()->getTerm(),
+ 'chatrooms' => $chatrooms,
+ 'user_admin' => $this->core->getUser()->accessAdmin()
+ ]);
+ }
+
+ public function showChatroom($chatroom, $anonymous = false) {
+ $this->core->getOutput()->addBreadcrumb("Chatroom");
+ $user = $this->core->getUser();
+ $display_name = $user->getDisplayFullName();
+ if (!$anonymous) {
+ if (!$user->accessAdmin()) {
+ $display_name = $user->getDisplayedGivenName() . " " . substr($user->getDisplayedFamilyName(), 0, 1) . ".";
+ }
+ }
+ else {
+ $adjectives = ["Quick", "Lazy", "Cheerful", "Pensive", "Mysterious", "Bright", "Sly", "Brave", "Calm", "Eager", "Fierce", "Gentle", "Jolly", "Kind", "Lively", "Nice", "Proud", "Quiet", "Rapid", "Swift"];
+ $anon_names = ["Duck", "Goose", "Swan", "Eagle", "Parrot", "Owl", "Sparrow", "Robin", "Pigeon", "Falcon", "Hawk", "Flamingo", "Pelican", "Seagull", "Cardinal", "Canary", "Finch", "Hummingbird"];
+ $display_name = 'Anonymous' . ' ' . $adjectives[array_rand($anon_names)] . ' ' . $anon_names[array_rand($anon_names)];
+ }
+
+ return $this->core->getOutput()->renderTwigTemplate("chat/Chatroom.twig", [
+ 'csrf_token' => $this->core->getCsrfToken(),
+ 'base_url' => $this->core->buildCourseUrl() . '/chat',
+ 'semester' => $this->core->getConfig()->getTerm(),
+ 'course' => $this->core->getConfig()->getCourse(),
+ 'chatroom' => $chatroom,
+ 'user_admin' => $this->core->getUser()->accessAdmin(),
+ 'user_id' => $this->core->getUser()->getId(),
+ 'user_display_name' => $display_name,
+ 'anonymous' => $anonymous,
+ ]);
+ }
+}
diff --git a/site/config/course_template.json b/site/config/course_template.json
index 2459e6cf0c5..ab9bd9e4a52 100644
--- a/site/config/course_template.json
+++ b/site/config/course_template.json
@@ -29,6 +29,7 @@
"polls_pts_for_incorrect": 0.0,
"seek_message_enabled": false,
"seek_message_instructions": "Optionally, provide your local timezone, desired project topic, or other information that would be relevant to forming your team.",
- "git_autograding_branch": "main"
+ "git_autograding_branch": "main",
+ "chat_enabled": false
}
}
diff --git a/site/public/css/chatroom.css b/site/public/css/chatroom.css
new file mode 100644
index 00000000000..ebc16cb42fe
--- /dev/null
+++ b/site/public/css/chatroom.css
@@ -0,0 +1,108 @@
+.chatroom-container {
+ width: 60%;
+ box-shadow: 0 0 2px 0 var(--default-black);
+ left: 30%;
+ transform: translate(30%, 0);
+}
+
+.chatroom-header {
+ padding-top: 0.5rem;
+ padding-bottom: 1rem;
+ box-shadow: 0 4px 2px -2px rgb(0 0 0 / 20%);
+}
+
+.chatroom-title {
+ font-size: 1.3rem;
+ padding-left: 1rem;
+}
+
+.leave-room {
+ float: right;
+ margin-right: 1rem;
+}
+
+.messages-area {
+ overflow-y: auto;
+ height: 38rem;
+ margin-left: 1rem;
+}
+
+.message-container {
+ padding-bottom: 0.5rem;
+ padding-top: 0.5rem;
+ margin-right: 1rem;
+}
+
+.message-container:hover {
+ background-color: var(--standard-hover-light-gray);
+}
+
+.message-header {
+ margin-bottom: 3px;
+ margin-left: 0.5rem;
+}
+
+.user-icon {
+ margin-right: 0.5rem;
+}
+
+.sender-name {
+ font-weight: bold;
+}
+
+.timestamp {
+ color: var(--subheading-underscore-grey);
+ font-size: 0.8rem;
+ margin-left: 5px;
+}
+
+.message-content {
+ max-width: 90%;
+ word-break: break-word;
+ margin-left: 0.5rem;
+}
+
+.input-container {
+ display: flex;
+ align-items: center;
+ box-shadow: 0 -2px 10px 0 rgb(0 0 0 / 5%);
+ padding: 0.5rem;
+}
+
+.message-input {
+ border-radius: 5px;
+ outline: none;
+ resize: none;
+ overflow: hidden;
+ font-size: 15px;
+ min-width: 85%;
+ margin-left: 1rem;
+}
+
+.message-input:focus {
+ background-color: var(--standard-hover-light-gray);
+}
+
+.send-message-btn {
+ margin-left: 1rem;
+ border: none;
+ border-radius: 25px;
+ cursor: pointer;
+ outline: none;
+}
+
+.chatroom-toast {
+ opacity: 0;
+ position: fixed;
+ top: 20px;
+ left: 50%;
+ transform: translateX(-50%);
+ background-color: var(--standard-medium-green);
+ color: var(--always-default-white);
+ padding: 15px;
+ border-radius: 25px;
+ box-shadow: 0 0 2px 0 var(--default-black);
+ text-align: center;
+ transition: opacity 1s;
+ z-index: 1000;
+}
diff --git a/site/public/js/chatroom.js b/site/public/js/chatroom.js
new file mode 100644
index 00000000000..db331d06eb2
--- /dev/null
+++ b/site/public/js/chatroom.js
@@ -0,0 +1,247 @@
+/* global csrfToken */
+
+// eslint-disable-next-line no-unused-vars
+function fetchMessages(chatroomId, my_id, when = new Date(0)) {
+ $.ajax({
+ // eslint-disable-next-line no-undef
+ url: buildCourseUrl(['chat', chatroomId, 'messages']),
+ type: 'GET',
+ dataType: 'json',
+ success: function(responseData) {
+ if (responseData.status === 'success' && Array.isArray(responseData.data)) {
+ responseData.data.forEach(msg => {
+ let display_name = msg.display_name;
+ if (msg.user_id === my_id) {
+ display_name = 'me';
+ }
+
+ appendMessage(display_name, msg.role, msg.timestamp, msg.content, false);
+ });
+ const messages_area = document.querySelector('.messages-area');
+ messages_area.scrollTop = messages_area.scrollHeight;
+ }
+ },
+ error: function() {
+ window.alert('Something went wrong with fetching messages');
+ },
+ });
+}
+
+// eslint-disable-next-line no-unused-vars
+function sendMessage(chatroomId, userId, displayName, role, content) {
+ $.ajax({
+ // eslint-disable-next-line no-undef
+ url: buildCourseUrl(['chat', chatroomId, 'send']),
+ type: 'POST',
+ data: {
+ 'csrf_token': csrfToken,
+ 'user_id': userId,
+ 'display_name': displayName,
+ 'role': role,
+ 'content': content,
+ },
+ success: function (response) {
+ try {
+ // eslint-disable-next-line no-unused-vars
+ console.log(response);
+ const json = JSON.parse(response);
+ }
+ catch (e) {
+ // eslint-disable-next-line no-undef
+ displayErrorMessage('Error parsing data. Please try again.');
+ return;
+ }
+ window.socketClient.send({'type': 'chat_message', 'content': content, 'user_id': userId, 'display_name': displayName, 'role': role, 'timestamp': new Date(Date.now()).toLocaleString()});
+ },
+ error: function() {
+ window.alert('Something went wrong with storing message');
+ },
+ });
+ appendMessage(displayName, role, null, content);
+}
+
+function appendMessage(displayName, role, ts, content, pin) {
+ let timestamp = ts;
+ if (!timestamp) {
+ timestamp = new Date(Date.now()).toLocaleString('en-us', { year:'numeric', month:'short', day:'numeric', hour:'numeric', minute:'numeric'});
+ }
+ else {
+ timestamp = new Date(ts).toLocaleString('en-us', { year:'numeric', month:'short', day:'numeric', hour:'numeric', minute:'numeric'});
+ }
+
+ let display_name = displayName;
+ if (role && role !== 'student' && display_name !== 'me' && display_name.substring(0, 9) !== 'Anonymous') {
+ display_name = `${displayName} [${role}]`;
+ }
+
+ const messages_area = document.querySelector('.messages-area');
+ const message = document.createElement('div');
+ if(pin) {
+ message.classList.add('pinned-message');
+ }
+ message.classList.add('message-container');
+ if (role === 'instructor') {
+ message.classList.add('admin-message');
+ }
+
+ const messageHeader = document.createElement('div');
+ messageHeader.classList.add('message-header');
+
+ const senderName = document.createElement('span');
+ senderName.classList.add('sender-name');
+ senderName.innerText = display_name;
+
+ const timestampSpan = document.createElement('span');
+ timestampSpan.classList.add('timestamp');
+ timestampSpan.innerText = timestamp;
+
+ messageHeader.appendChild(senderName);
+ messageHeader.appendChild(timestampSpan);
+
+ const messageContent = document.createElement('div');
+ messageContent.classList.add('message-content');
+ messageContent.innerText = content;
+
+ message.appendChild(messageHeader);
+ message.appendChild(messageContent);
+
+ messages_area.appendChild(message);
+
+ // automatically scroll to bottom for new messages, if close to bottom
+ const distanceFromBottom = messages_area.scrollHeight - messages_area.scrollTop - messages_area.clientHeight;
+ if ( distanceFromBottom < 110) {
+ messages_area.scrollTop = messages_area.scrollHeight;
+ }
+}
+
+function initChatroomSocketClient(chatroomId) {
+ // eslint-disable-next-line no-undef
+ window.socketClient = new WebSocketClient();
+ window.socketClient.onmessage = (msg) => {
+ if (msg.type === 'chat_message') {
+ const sender_name = msg.display_name;
+ const role = msg.role;
+ appendMessage(sender_name, role, msg.timestamp, msg.content);
+ }
+ };
+ window.socketClient.open(`chatroom_${chatroomId}`);
+}
+
+// eslint-disable-next-line no-unused-vars
+function newChatroomForm() {
+ const form = $('#create-chatroom-form');
+ form.css('display', 'block');
+ document.getElementById('chatroom-allow-anon').checked = true;
+}
+
+// eslint-disable-next-line no-unused-vars
+function editChatroomForm(chatroom_id, baseUrl, title, description, allow_anon) {
+ const form = $('#edit-chatroom-form');
+ form.css('display', 'block');
+ document.getElementById('chatroom-edit-form').action = `${baseUrl}/${chatroom_id}/edit`;
+ document.getElementById('chatroom-title-input').value = title;
+ document.getElementById('chatroom-description-input').value = description;
+ if (allow_anon) {
+ document.getElementById('chatroom-allow-anon').checked = true;
+ }
+}
+
+// eslint-disable-next-line no-unused-vars
+function deleteChatroomForm(chatroom_id, chatroom_name, base_url) {
+ if (confirm(`This will delete chatroom '${chatroom_name}'. Are you sure?`)) {
+ const url = `${base_url}/delete`;
+ const fd = new FormData();
+ fd.append('csrf_token', csrfToken);
+ fd.append('chatroom_id', chatroom_id);
+ $.ajax({
+ url: url,
+ type: 'POST',
+ data: fd,
+ processData: false,
+ cache: false,
+ contentType: false,
+ success: function(data) {
+ try {
+ const msg = JSON.parse(data);
+ if (msg.status !== 'success') {
+ console.error(msg);
+ window.alert('Something went wrong. Please try again.');
+ }
+ else {
+ window.location.reload();
+ }
+ }
+ catch (err) {
+ console.error(err);
+ window.alert('Something went wrong. Please try again.');
+ }
+ },
+ error: function(err) {
+ console.error(err);
+ window.alert('Something went wrong. Please try again.');
+ },
+ });
+ }
+}
+
+// eslint-disable-next-line no-unused-vars
+function toggle_chatroom(chatroomId, active) {
+ const form = document.getElementById(`chatroom_toggle_form_${chatroomId}`);
+ if (active) {
+ if (confirm('This will terminate this chatroom session. Are you sure?')) {
+ form.submit();
+ }
+ }
+ else {
+ form.submit();
+ }
+}
+
+function showJoinMessage(message) {
+ const toast = document.querySelector('.chatroom-toast');
+ toast.textContent = message;
+ toast.style.visibility = 'visible';
+ toast.style.opacity = '0.85';
+ setTimeout(() => {
+ toast.style.opacity = '0'; // fade out
+ }, 3000);
+}
+
+document.addEventListener('DOMContentLoaded', () => {
+ const pageDataElement = document.getElementById('page-data');
+ if (pageDataElement) {
+ const pageData = JSON.parse(pageDataElement.textContent);
+ // eslint-disable-next-line no-unused-vars
+ const { chatroomId, userId, displayName, user_admin, isAnonymous } = pageData;
+
+ showJoinMessage(`You have successfully joined as ${displayName}.`);
+
+ initChatroomSocketClient(chatroomId);
+
+ fetchMessages(chatroomId, userId);
+
+ const sendButton = document.querySelector('.send-message-btn');
+ const messageInput = document.querySelector('.message-input');
+
+ messageInput.addEventListener('keypress', (event) => {
+ if (event.keyCode === 13 && !event.shiftKey) {
+ event.preventDefault();
+ sendButton.click();
+ }
+ });
+
+ sendButton.addEventListener('click', (event) => {
+ event.preventDefault();
+ const messageContent = messageInput.value.trim();
+ if (messageContent === '') {
+ alert('Please enter a message.');
+ return;
+ }
+
+ const role = user_admin ? 'instructor' : 'student';
+ sendMessage(chatroomId, userId, displayName, role, messageContent);
+
+ messageInput.value = '';
+ });
+ }
+});