/**
* @file DTPS pages & discussion functions
* @author jottocraft
*
* @copyright Copyright (c) 2018-2023 jottocraft
* @license MIT
*/
/**
* Renders the discussions list for a class
*
* @param {string} courseID The course ID to render discussion threads for
* @param {string} [defaultThread] The thread to load by default
* @param {boolean} [fromModules] True if this page is being loaded from the moduleStream
* @param {string} [defaultPost] The post to load by default
*/
dtps.loadThreadsList = function (courseID, defaultThread, fromModules, defaultPost) {
//Get class index and set as selected class
var classNum = dtps.classes.map(course => course.id).indexOf(courseID);
dtps.selectedClass = classNum;
//Set discussions as the selected content
if (!fromModules) {
dtps.selectedContent = "discuss";
$("#dtpsTabBar .btn").removeClass("active");
$("#dtpsTabBar .btn.discuss").addClass("active");
jQuery("body").removeClass("collapsedSidebar");
}
if (classNum == -1) {
//Class does not exist
dtps.error("The selected class doesn't exist", "classNum check failed @ dtps.loadThreadsList");
}
//Load class color and things
dtps.presentClass(classNum);
//Load module thread
if (fromModules) {
dtps.loadThreadPosts(classNum, defaultThread, fromModules, defaultPost);
return;
}
if ((dtps.selectedClass == classNum) && (dtps.selectedContent == "discuss")) {
//Render sidebar
jQuery(".sidebar").html(/*html*/`
<div class="title">
<i style="font-size: 28px; margin-right: 7px; vertical-align: middle;" class="fluid-icon">forum</i>
<h4>Discussions</h4>
</div>
<div class="items">
<div onclick="fluid.screen('stream', '${dtps.classes[classNum].id}');" class="item main back">
<i class="fluid-icon">keyboard_arrow_left</i> <span class="label">Classes</span>
</div>
<div class="divider"></div>
<div class="item shimmerParent">
<i class="fluid-icon">star</i>
<span class="label">Discussion thread Title</span>
</div>
<div class="item shimmerParent">
<i class="fluid-icon">star</i>
<span class="label">Discussion thread Title</span>
</div>
<div class="item shimmerParent">
<i class="fluid-icon">star</i>
<span class="label">Discussion thread Title</span>
</div>
</div>
`);
//Delete existing class content HTML
jQuery(".classContent").html("");
}
//Fetch discussion thread data
dtpsLMS.fetchDiscussionThreads(dtps.classes[classNum].lmsID).then(function (threadData) {
//Store fetched data to the selected class
dtps.classes[classNum].discussions = threadData;
if (dtps.classes[classNum].discussions.length == 0) {
//No discussion topics in this class
jQuery(".sidebar .items").html(/*html*/`
<div onclick="fluid.screen('stream', '${dtps.classes[classNum].id}');" class="item main back">
<i class="fluid-icon">keyboard_arrow_left</i> <span class="label">Classes</span>
</div>
<p style="text-align: center; font-weight: bold; margin-top: 60px; margin-bottom: 10px;">No discussions found</p>
<p style="text-align: center; font-size: 14px; margin: 0px;">This class doesn't have any discussions</p>
`);
} else {
//Loop over discusson threads array to create discussion thread HTML for the sidebar
var discussionHTML = dtps.classes[classNum].discussions.map(discussionThread => {
return /*html*/`
<div data-threadID="${discussionThread.id}" class="item">
<i class="fluid-icon">${discussionThread.locked ? "lock_outline" : "chat_bubble_outline"}</i>
<span class="label">${discussionThread.title}</span>
</div>
`;
}).join("");
//Render discussion threads in the sidebar
if ((dtps.selectedClass == classNum) && (dtps.selectedContent == "discuss")) {
jQuery(".sidebar .items").html(/*html*/`
<div onclick="fluid.screen('stream', '${dtps.classes[classNum].id}');" class="item main back">
<i class="fluid-icon">keyboard_arrow_left</i> <span class="label">Classes</span>
</div>
${
dtps.classes[classNum].newDiscussionThreadURL ? (/*html*/`
<div onclick="window.open('${dtps.classes[classNum].newDiscussionThreadURL}')" class="item back">
<i class="fluid-icon">add</i>
<span class="label">New discussion</span>
</div>
`) : ""
}
<div class="divider"></div>
${discussionHTML}
`);
//Load default thread if provided
if (defaultThread) {
dtps.loadThreadPosts(classNum, defaultThread, fromModules, defaultPost);
}
//Add click event listeners for discussion threads
$(".sidebar .item:not(.back)").click(function (event) {
//Show the selected thread as active
$(this).siblings().removeClass("active");
$(this).addClass("active");
//Get thread ID from HTML attribute
var postID = $(this).attr("data-threadID");
//Load the thread
dtps.loadThreadPosts(classNum, postID);
});
}
}
}).catch(function (err) {
dtps.error("Couldn't fetch discussion threads", "Caught promise rejection @ dtps.loadThreadsList", err);
});
}
/**
* Fetches and displays posts in a discussion
*
* @param {number} classNum The class number to render
* @param {string} threadID The discussion thread to render
* @param {boolean} [fromModules] True if this page is being loaded from the moduleStream
* @param {string} [defaultPost] The post ID to jump to
*/
dtps.loadThreadPosts = function (classNum, threadID, fromModules, defaultPost) {
//Show loading indicator
if ((dtps.selectedClass == classNum) && ((dtps.selectedContent == "discuss") || fromModules)) {
jQuery(".classContent").html(/*html*/`
<div class="card" style="margin-top: 20px;margin-bottom: 75px;">
<h4><span class="shimmer">[SHIMMER] Thread Title</span></h4>
<div style="margin-bottom: 32px;" class="discussionHeader">
<h5 ><span class="shimmer">Discussion author name</span></h5>
<i class="fluid-icon shimmer">calendar_today</i>
<span class="shimmer">Discussion date</span>
</div>
<div class="shimmer" style="margin: 10px 0px; width: 100%; height: 400px; border: none; outline: none;"></div>
<div style="margin-top: 32px;" class="discussionFooter">
<button class="btn small shimmer"><i class="fluid-icon">post_add</i> Add Post</button>
</div>
</div>
`);
}
//Fetch discussion posts from the LMS
dtpsLMS.fetchDiscussionPosts(dtps.classes[classNum].lmsID, threadID).then(function (thread) {
//Post HTML array
var postHTML = [];
thread.posts.forEach((post, index) => {
//Get background and text color for iFrame content
var computedBackgroundColor = getComputedStyle($(".card.details")[0]).getPropertyValue("--cards");
var computedTextColor = getComputedStyle($(".card.details")[0]).getPropertyValue("--text");
//Create a new blob data URL with the post's content
var blob = new Blob([`
<base target="_blank" />
<link type="text/css" rel="stylesheet" href="https://cdn.jottocraft.com/CanvasCSS.css" media="screen,projection"/>
<style>body {background-color: ${computedBackgroundColor}; color: ${computedTextColor};</style>
${post.body}
`], { type: 'text/html' });
var discussionPostContentURL = window.URL.createObjectURL(blob);
//Create HTML for replies
var replyHTML = [];
if (post.replies) {
//Add space above replies
replyHTML.push(`<br />`);
//Get HTML for each reply and add to array
post.replies.forEach(reply => {
replyHTML.push(/*html*/`
<div data-post-id="${reply.id}" style="margin-left: ${(reply.depth || 0) * 50}px;" class="discussionReply">
<div class="discussionHeader">
${post.author ? /*html*/`
<img src="${reply.author.photoURL}" />
<h5>${reply.author.name}</h5>
` : ``}
<span style="cursor: pointer;" onclick="window.open('${reply.replyURL}');">
<i class="fluid-icon">reply</i>
<span>Reply</span>
</span>
</div>
${reply.body}
</div>
`)
});
//Add space below replies
replyHTML.push(`<br />`);
}
//Create HTML for this post
//Note that index == 0 is the initial post
postHTML.push(/*html*/`
<div class="card" data-post-id="${post.id}" style="margin-top: 20px;${index == 0 ? "margin-bottom: 75px;" : "padding: 20px 30px;"}">
<!-- Thread title (Initial post) -->
${index == 0 ? /*html*/`
<h4 style="font-weight: bold">${thread.title}</h4>
` : ''}
<!-- Author header -->
<div ${index == 0 ? `style="margin-bottom: 32px;"` : ""} class="discussionHeader">
${post.author ? /*html*/`
<img src="${post.author.photoURL}" />
<h5>${post.author.name}</h5>
` : ``}
<i class="fluid-icon">calendar_today</i>
<span>${dtps.formatDate(post.postedAt)}</span>
<!-- Thread info (initial post) -->
${index == 0 ? /*html*/`
${thread.locked ? `<i class="fluid-icon">lock</i>` : ""}
${thread.requireInitialPost ? /*html*/`
<i class="fluid-icon">visibility</i>
<span>You must post before you can see other replies</span>
` : ""}
` : ''}
</div>
${index == 0 ? /*html*/`
<iframe id="classThreadIframe" onload="dtps.iframeLoad('classThreadIframe')" style="margin: 10px 0px; width: 100%; border: none; outline: none;" src="${discussionPostContentURL}" />
` : post.body}
${replyHTML.join("")}
<!-- Reply / Add post footer -->
${post.replyURL ? /*html*/`
<div ${index == 0 ? `style="margin-top: 32px;"` : ""} class="discussionFooter">
${index == 0 ? /*html*/`
<button onclick="window.open('${post.replyURL}');" class="btn small"><i class="fluid-icon">post_add</i> Add Post</button>
` : /*html*/`
<button onclick="window.open('${post.replyURL}');" class="btn small"><i class="fluid-icon">reply</i> Reply</button>
`}
</div>
` : ""}
</div>
`);
});
//Render HTML
if ((dtps.selectedClass == classNum) && ((dtps.selectedContent == "discuss") || fromModules)) {
jQuery(".classContent").html((fromModules ? /*html*/`
<button style="margin: 10px 0px 0px 20px;" class="btn" onclick="fluid.screen('moduleStream', '${dtps.classes[classNum].id}')">
<i class="fluid-icon">keyboard_arrow_left</i> Back
</button>
` : "") + postHTML.join(""));
//Jump to post if specified
if (defaultPost) {
$("[data-post-id=" + defaultPost + "]").get(0).scrollIntoView();
}
}
}).catch(function (err) {
dtps.error("Could not fetch discussion posts", "Caught promise rejection @ dtps.loadThreadPosts", err);
});
}
/**
* Renders the pages list for a class
*
* @param {string} courseID The course ID to render pages for
* @param {string} [defaultPage] If provided, load the pageID by default
* @param {boolean} fromModules True if this page is being loaded from the moduleStream
*/
dtps.loadPagesList = function (courseID, defaultPage, fromModules) {
//Get class index and set as selected class
var classNum = dtps.classes.map(course => course.id).indexOf(courseID);
dtps.selectedClass = classNum;
//Set pages as the selected content
if (!fromModules) {
dtps.selectedContent = "pages";
$("#dtpsTabBar .btn").removeClass("active");
$("#dtpsTabBar .btn.pages").addClass("active");
jQuery("body").removeClass("collapsedSidebar");
}
if (classNum == -1) {
//Class does not exist
dtps.error("The selected class doesn't exist", "classNum check failed @ dtps.loadPagesList");
}
//Load class color and things
dtps.presentClass(classNum);
//Load module page
if (fromModules) {
dtps.loadPage(classNum, defaultPage, fromModules);
return;
}
if ((dtps.selectedClass == classNum) && (dtps.selectedContent == "pages")) {
//Render sidebar
jQuery(".sidebar").html(/*html*/`
<div class="title">
<i style="font-size: 28px; margin-right: 7px; vertical-align: middle;" class="fluid-icon">insert_drive_file</i>
<h4>Pages</h4>
</div>
<div class="items">
<div onclick="fluid.screen('stream', '${dtps.classes[classNum].id}');" class="item main back">
<i class="fluid-icon">keyboard_arrow_left</i> <span class="label">Classes</span>
</div>
<div class="divider"></div>
<div class="item shimmerParent">
<i class="fluid-icon">star</i>
<span class="label">Class page title</span>
</div>
<div class="item shimmerParent">
<i class="fluid-icon">star</i>
<span class="label">Class page title</span>
</div>
<div class="item shimmerParent">
<i class="fluid-icon">star</i>
<span class="label">Class page title</span>
</div>
</div>
`);
//Delete existing class content HTML
jQuery(".classContent").html("");
}
dtpsLMS.fetchPages(dtps.classes[classNum].lmsID).then(function (pagesData) {
//Store fetched pages data to the class
dtps.classes[classNum].pages = pagesData;
if (pagesData.length == 0) {
//No pages in this class
jQuery(".sidebar .items").html(/*html*/`
<div onclick="fluid.screen('stream', '${dtps.classes[classNum].id}');" class="item main back">
<i class="fluid-icon">keyboard_arrow_left</i>
<span class="label">Classes</span>
</div>
<p style="text-align: center; font-weight: bold; margin-top: 60px; margin-bottom: 10px;">No pages found</p>
<p style="text-align: center; font-size: 14px; margin: 0px;">This class doesn't have any pages</p>
`);
} else {
//Loop over discusson threads array to create discussion thread HTML for the sidebar
var pageHTML = dtps.classes[classNum].pages.map(page => {
return /*html*/`
<div data-pageID="${page.id}" class="item ${(defaultPage && (page.id == defaultPage)) ? "active" : ""}">
<i class="fluid-icon">insert_drive_file</i>
<span class="label">${page.title}</span>
</div>
`;
}).join("");
//Render discussion threads in the sidebar
if ((dtps.selectedClass == classNum) && (dtps.selectedContent == "pages")) {
jQuery(".sidebar .items").html(/*html*/`
<div onclick="fluid.screen('stream', '${dtps.classes[classNum].id}');" class="item main back">
<i class="fluid-icon">keyboard_arrow_left</i>
<span class="label">Classes</span>
</div>
<div class="divider"></div>
${pageHTML}
`);
}
//Load default page if provided
if (defaultPage) {
dtps.loadPage(classNum, defaultPage);
}
//Add click event listeners for pages
$(".sidebar .item:not(.back)").click(function (event) {
//Show the selected thread as active
$(this).siblings().removeClass("active");
$(this).addClass("active");
//Get page ID from HTML attribute
var pageID = $(this).attr("data-pageID");
//Load the page content
dtps.loadPage(classNum, pageID);
});
}
}).catch((err) => {
dtps.error("Couldn't fetch pages", "Caught promise rejection @ dtps.loadPagesList", err);
});
}
/**
* Fetches and renders a page and its contents
*
* @param {string} classNum The class number of the page to render
* @param {string} pageID The page ID to render
* @param {boolean} fromModules True if this page is being loaded from the moduleStream
*/
dtps.loadPage = function (classNum, pageID, fromModules) {
//Show loading indicator
if ((dtps.selectedClass == classNum) && ((dtps.selectedContent == "pages") || fromModules)) {
jQuery(".classContent").html(/*html*/`
<div style="margin: 20px; padding: 20px;">
<h4><span class="shimmer">[SHIMMER] Class page title</span></h4>
<div style="margin-bottom: 32px;" class="discussionHeader">
<h5 class="shimmer">Page author name</h5>
<i class="fluid-icon shimmer">calendar_today</i>
<span class="shimmer">[SHIMMER] Last updated at</span>
</div>
<!-- Page content -->
<br />
<div class="shimmer" style="margin: 10px 0px; width: 100%; height: 600px; border: none; outline: none;"></div>
</div>
`);
}
//Fetch page content
dtpsLMS.fetchPageContent(dtps.classes[classNum].lmsID, pageID).then(function (page) {
if ((dtps.selectedClass == classNum) && ((dtps.selectedContent == "pages") || fromModules)) {
//Get computed background and text color to style the iFrame with
var computedBackgroundColor = getComputedStyle($(".classContent")[0]).getPropertyValue("--background");
var computedTextColor = getComputedStyle($(".card.details")[0]).getPropertyValue("--text");
//Generate a blob with the page content and get its data URL
var blob = new Blob([`
<base target="_blank" />
<link type="text/css" rel="stylesheet" href="https://cdn.jottocraft.com/CanvasCSS.css" media="screen,projection"/>
<style>body {background-color: ${computedBackgroundColor}; color: ${computedTextColor};</style>
${page.content}
`], { type: 'text/html' });
//Get URL from blob
var pageContentURL = window.URL.createObjectURL(blob);
//Render page card in the class content
jQuery(".classContent").html(/*html*/`
${fromModules ? /*html*/`
<button style="margin: 10px 0px 0px 20px;" class="btn" onclick="fluid.screen('moduleStream', '${dtps.classes[classNum].id}')">
<i class="fluid-icon">keyboard_arrow_left</i> Back
</button>
` : ""}
<div style="margin: 20px; padding: 20px;">
<!-- Page title -->
<h4 style="font-weight: bold;">${page.title}</h4>
<!-- Page header -->
<div style="margin-bottom: 32px;" class="discussionHeader">
${page.author ? /*html*/`
<img src="${page.author.photoURL}" />
<h5>${page.author.name}</h5>
` : ``}
<i class="fluid-icon">calendar_today</i>
<span>${"Last Updated " + dtps.formatDate(page.updatedAt)}</span>
</div>
<!-- Page content -->
<br />
<iframe id="classPageIframe" onload="dtps.iframeLoad('classPageIframe')" style="margin: 10px 0px; width: 100%; border: none; outline: none;" src="${pageContentURL}" />
</div>
`);
}
}).catch((err) => {
dtps.error("Couldn't load page", "Caught promise rejection @ dtps.loadPage", err);
});
}
//Fluid UI screen definitions
fluid.externalScreens.discussions = (param) => {
//Split parameter string into variables
var courseID = param.split("|")[0];
var threadID = param.split("|")[1];
var fromModules = param.split("|")[2] == "true";
var postID = param.split("|")[3];
dtps.loadThreadsList(courseID, threadID, fromModules, postID);
}
fluid.externalScreens.pages = (param) => {
//Split parameter string into variables
var courseID = param.split("|")[0];
var pageID = param.split("|")[1];
var fromModules = param.split("|")[2] == "true";
dtps.loadPagesList(courseID, pageID, fromModules);
}
//Type definitions
/**
* @typedef {Object} PartialDiscussionThread
* @description Defines partial discussion thread objects in DTPS (for threads list)
* @property {string} title Title of the discussion thread
* @property {string} id Discussion thread ID
* @property {boolean} [locked] True if posting to the discussion thread is locked
*/
/**
* @typedef {Object} DiscussionThread
* @description Defines discussion thread objects in DTPS
* @property {string} title Title of the discussion thread
* @property {string} id Discussion thread ID
* @property {DiscussionPost[]} posts Posts in this thread, with the initial one first.
* @property {boolean} [locked] True if posting to the discussion thread is locked
* @property {boolean} [requireInitialPost] True if the user must post before viewing others' posts
*/
/**
* @typedef {Object} DiscussionPost
* @description Defines discussion post objects in DTPS
* @property {string} id Discussion post ID
* @property {string} body Discussion post body HTML
* @property {Date} postedAt Date for when the post was posted
* @property {number} [depth] The depth level this post is
* @property {User} [author] Discussion post author
* @property {DiscussionPost[]} [replies] Replies to this post. Nested replies (replies to replies) should be after this post in the array with a depth of 1, not in the replies key.
* @property {string} [replyURL] A URL that the user can visit to reply to this post
*/
/**
* @typedef {Object} PartialPage
* @description Defines a partial page object in DTPS (for pages list)
* @property {string} title Page title
* @property {string} id Page ID
*/
/**
* @typedef {Object} Page
* @description Defines Page objects in DTPS
* @property {string} title Page title
* @property {string} id Page ID
* @property {string} content Page content HTML
* @property {Date} [updatedAt] When the page was last updated
* @property {User} [author] Page author
*/