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 %} + + + + + + + + + + + + + + + + + + {% for chatroom in chatrooms %} + + + + + + + + + {% endfor %} + + {% else %} + + + + + + + + + + + + + + {% for chatroom in chatrooms %} + {% if chatroom.isActive() %} + + + + + + + {% endif %} + {% endfor %} + + {% endif %} +
NameDescription
+ + + {% 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 %} +
NameHostDescription
+ + {{ 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 %} +
+
+
+{% 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 @@ +
+
+
+
+ {{ chatroom.getTitle() | length > 40 ? chatroom.getTitle() | slice(0, 40) ~ '...' : chatroom.getTitle() }} + leave +
+
+
+
+ + +
+
+
+
+ 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 = ''; + }); + } +});