Commit a0943ce1 authored by Aaron Wells's avatar Aaron Wells
Browse files

Allowing feedback to be threaded

Bug 884023

Change-Id: Ide9df8c7df229395fe4e3ae496a70fa9d4ffcab1
parent fba3c512
......@@ -212,6 +212,7 @@ if ($institution || $add) {
if (!$add) {
$data = get_record('institution', 'name', $institution);
$data->commentsortorder = get_config_institution($institution, 'commentsortorder');
$data->commentthreaded = get_config_institution($institution, 'commentthreaded');
$lockedprofilefields = (array) get_column('institution_locked_profile_field', 'profilefield', 'name', $institution);
// TODO: Find a better way to work around Smarty's minimal looping logic
......@@ -250,6 +251,7 @@ if ($institution || $add) {
$data->dropdownmenu = get_config('dropdownmenu') ? 1 : 0;
$data->skins = get_config('skins') ? 1 : 0;
$data->commentsortorder = 'earliest';
$data->commentthreaded = false;
$lockedprofilefields = array();
$authtypes = auth_get_available_auth_types();
......@@ -460,6 +462,12 @@ if ($institution || $add) {
),
'help' => true,
);
$elements['commentthreaded'] = array(
'type' => 'switchbox',
'title' => get_string('commentthreaded', 'admin'),
'description' => get_string('commentthreadeddescription', 'admin'),
'defaultvalue' => $data->commentthreaded,
);
// Some more fields that are hidden from the default institution
if (empty($data->name) || $data->name != 'mahara') {
$elements['showonlineusers'] = array(
......@@ -752,6 +760,7 @@ function institution_submit(Pieform $form, $values) {
require_once(get_config('docroot') . 'artefact/comment/lib.php');
$commentoptions = ArtefactTypeComment::get_comment_options();
$newinstitution->commentsortorder = (empty($values['commentsortorder'])) ? $commentoptions->sort : $values['commentsortorder'];
$newinstitution->commentthreaded = (!empty($values['commentthreaded'])) ? 1 : 0;
if ($newinstitution->theme == 'custom') {
if (!empty($oldinstitution->style)) {
......
......@@ -39,5 +39,10 @@ function xmldb_artefact_comment_upgrade($oldversion=0) {
);
}
if ($oldversion < 2015081000) {
// Set default maxindent for threaded comments
set_config_plugin('artefact', 'comment', 'maxindent', 5);
}
return $success;
}
......@@ -46,6 +46,8 @@ $string['feedbackonviewbyuser'] = 'Feedback on %s by %s';
$string['feedbacksubmitted'] = 'Feedback submitted';
$string['feedbacksubmittedmoderatedanon'] = 'Feedback submitted, awaiting moderation';
$string['feedbacksubmittedprivateanon'] = 'Private feedback submitted';
$string['forcepubliccomment'] = 'Public';
$string['forceprivatecomment'] = 'Private: This reply will only be visible to you and the author of the preceeding comment.';
$string['lastcomment'] = 'Last comment';
$string['makepublic'] = 'Make public';
$string['makepublicnotallowed'] = 'You are not allowed to make this comment public';
......@@ -65,6 +67,11 @@ $string['progress_feedback'] = array(
);
$string['rating'] = 'Rating';
$string['reallydeletethiscomment'] = 'Are you sure you want to delete this comment?';
$string['reply'] = 'Reply';
$string['replyto'] = 'Reply to:';
$string['replytonoaccess'] = 'You are not allowed to post a reply to this comment.';
$string['replytonoprivatereplyallowed'] = 'You are not allowed to post a private reply to this comment.';
$string['replytonopublicreplyallowed'] = 'You are not allowed to post a public reply to this comment.';
$string['thiscommentisprivate'] = 'This comment is private';
$string['typefeedback'] = 'Feedback';
$string['viewcomment'] = 'View comment';
......
......@@ -76,6 +76,8 @@ class PluginArtefactComment extends PluginArtefact {
foreach(ArtefactTypeComment::deleted_types() as $type) {
insert_record('artefact_comment_deletedby', (object)array('name' => $type));
}
set_config_plugin('artefact', 'comment', 'maxindent', '5');
}
}
......@@ -343,6 +345,7 @@ class ArtefactTypeComment extends ArtefactType {
$options->onview = false;
$sortorder = get_user_institution_comment_sort_order();
$options->sort = (!empty($sortorder)) ? $sortorder : 'earliest';
$options->threaded = null;
return $options;
}
......@@ -355,8 +358,10 @@ class ArtefactTypeComment extends ArtefactType {
*/
public static function get_comments($options) {
global $USER;
$allowedoptions = self::get_comment_options();
// set the object's key/val pairs as variables
foreach ($options as $key => $option) {
if (array_key_exists($key, $allowedoptions));
$$key = $option;
}
$userid = $USER->get('id');
......@@ -374,6 +379,14 @@ class ArtefactTypeComment extends ArtefactType {
$artefactid = null;
}
// Find out whether the page's owner has threaded comments or not
if ($owner) {
$threaded = get_user_institution_comment_threads($owner);
}
else {
$threaded = false;
}
$result = (object) array(
'limit' => $limit,
'offset' => $offset,
......@@ -384,6 +397,7 @@ class ArtefactTypeComment extends ArtefactType {
'isowner' => $isowner,
'export' => $export,
'sort' => $sort,
'threaded' => $threaded,
'data' => array(),
);
......@@ -394,15 +408,49 @@ class ArtefactTypeComment extends ArtefactType {
$where = 'c.onview = ' . (int)$viewid;
}
if (!$canedit) {
$where .= ' AND (c.private = 0 OR a.author = ' . (int) $userid . ')';
$where .= ' AND (';
$where .= 'c.private = 0 '; // Comment is public
$where .= 'OR a.author = ' . (int) $userid; // You are the comment author
if ($threaded) {
$where .= ' OR p.author = ' . (int) $userid; // you authored the parent
}
$where .= ')';
}
$result->count = count_records_sql('
SELECT COUNT(*)
FROM {artefact} a JOIN {artefact_comment_comment} c ON a.id = c.artefact
FROM
{artefact} a
JOIN {artefact_comment_comment} c
ON a.id = c.artefact
LEFT JOIN {artefact} p
ON a.parent = p.id
WHERE ' . $where);
if ($result->count > 0) {
// Figure out sortorder
if (!$threaded) {
$orderby = 'a.ctime ' . ($sort == 'latest' ? 'DESC' : 'ASC');
}
else {
if ($sort != 'latest') {
// Threaded ascending
$orderby = 'a.path ASC, a.ctime ASC, a.id';
}
else {
// Threaded & descending. Sort "root comments" by descending order, and the
// comments below them in ascending order. (This is the only sane way to do it.)
if (is_mysql()) {
$splitfunc = 'SUBSTRING_INDEX';
}
else {
$splitfunc = 'SPLIT_PART';
}
$orderby = "{$splitfunc}(a.path, '/', 2) DESC, a.path ASC, a.ctime ASC, a.id";
}
}
// If pagination is in use, see if we want to get a page with particular comment
if ($limit) {
if ($showcomment == 'last') {
......@@ -412,35 +460,45 @@ class ArtefactTypeComment extends ArtefactType {
else if (is_numeric($showcomment)) {
// Ignore $offset and get the page that has the comment
// with id $showcomment on it.
// Fetch everything up to $showcomment to get its rank
// Fetch everything and figure out which page $showcomment is in.
// This will get ugly if there are 1000s of comments
$ids = get_column_sql('
SELECT a.id
FROM {artefact} a JOIN {artefact_comment_comment} c ON a.id = c.artefact
WHERE ' . $where . ' AND a.id <= ?
ORDER BY a.ctime', array($showcomment));
$last = end($ids);
if ($last == $showcomment) {
SELECT a.id
FROM {artefact} a JOIN {artefact_comment_comment} c ON a.id = c.artefact
LEFT JOIN {artefact} p ON a.parent = p.id
WHERE ' . $where . '
ORDER BY ' . $orderby,
array()
);
$found = false;
foreach ($ids as $k => $v) {
if ($v == $showcomment) {
$found = $k;
break;
}
}
if ($found !== false) {
// Add 1 because array index starts from 0 and therefore key value is offset by 1.
$rank = key($ids) + 1;
$rank = $found + 1;
$result->forceoffset = $offset = ((ceil($rank / $limit) - 1) * $limit);
$result->showcomment = $showcomment;
}
}
}
$sortorder = (!empty($sort) && $sort == 'latest') ? 'a.ctime DESC' : 'a.ctime ASC';
$comments = get_records_sql_assoc('
SELECT
a.id, a.author, a.authorname, a.ctime, a.mtime, a.description, a.group,
c.private, c.deletedby, c.requestpublic, c.rating, c.lastcontentupdate,
u.username, u.firstname, u.lastname, u.preferredname, u.email, u.staff, u.admin,
u.deleted, u.profileicon, u.urlid
u.deleted, u.profileicon, u.urlid, a.path, p.id AS parent, p.author AS parentauthor
FROM {artefact} a
INNER JOIN {artefact_comment_comment} c ON a.id = c.artefact
LEFT JOIN {artefact} p
ON a.parent = p.id
LEFT JOIN {usr} u ON a.author = u.id
WHERE ' . $where . '
ORDER BY ' . $sortorder, array(), $offset, $limit);
ORDER BY ' . $orderby, array(), $offset, $limit);
$files = ArtefactType::attachments_from_id_list(array_keys($comments));
......@@ -451,6 +509,27 @@ class ArtefactTypeComment extends ArtefactType {
}
}
// calculate the indent tabs for the comments
$max_depth = ($threaded ? get_config_plugin('artefact', 'comment', 'maxindent') : 1);
$usercache = array($userid => $canedit);
foreach($comments as &$c) {
// You can post a public reply to a comment if you can see it & the comment is not private
$c->canpublicreply = (int) self::can_public_reply_to_comment($c->private);
$c->canprivatereply = (int) self::can_private_reply_to_comment(
$c->private,
$userid,
$c->author,
$c->parentauthor,
$artefact,
$view
);
$c->canreply = ($threaded && ($c->canpublicreply || $c->canprivatereply)) ? 1 : 0;
$c->indent = ($max_depth == 1) ? 1 : min($max_depth, substr_count($c->path, '/'));
// Count indent levels starting from 0 instead of 1.
$c->indent -= 1;
}
$result->data = array_values($comments);
}
......@@ -470,6 +549,78 @@ class ArtefactTypeComment extends ArtefactType {
return $result;
}
/**
* Can you post a public reply to this comment?
* (Made into a separate function so we can re-use the logic)
* @param boolean $isprivate Is the comment private?
* @return boolean
*/
public static function can_public_reply_to_comment($isprivate) {
return !$isprivate;
}
/**
* Can you post a private reply to this comment?
* (Made into a separate function so we can re-use the logic)
* @param boolean $isprivate Is the replied-to comment private?
* @param int $commenter User replying to the comment
* @param int $author Author of the replied-to comment
* @param int $parentauthor Author of the replied-to comment's parent
* @param ArtefactType $artefact The artefact being commented on (or null)
* @param View $view The view being commented on (or null)
* @return boolean
*/
public static function can_private_reply_to_comment($isprivate, $commenter, $author, $parentauthor, $artefact=null, $view=null) {
// No private replies to anonymous comments
// (It would be impossible for the commenter to see!)
if (!$author) {
return false;
}
// No private replies to your own private comments
if ($isprivate && $author == $commenter) {
return false;
}
// You can post a private reply to a comment that is a private reply to one of your comments
if ($isprivate && $parentauthor == $commenter) {
return true;
}
// The page owner can post private replies to others' comments
if (self::can_moderate_comments($commenter, $artefact, $view)) {
return true;
}
// Other users can post a private reply to a comment by the page owner.
return self::can_moderate_comments($author, $artefact, $view);
}
/**
* Whether a user can moderate comments on a particular (view or artefact) page
* @param int $userid
* @param ArtefactType $artefact
* @param View $view
* @return boolean
*/
public static function can_moderate_comments($userid, $artefact=null, $view=null) {
static $usercache = array();
if (array_key_exists($userid, $usercache)) {
return $usercache[$userid];
}
$user = new User();
$user->find_by_id($userid);
if ($artefact) {
$canmod = $user->can_edit_artefact($artefact);
}
else {
$canmod = $user->can_moderate_view($view);
}
$usercache[$userid] = $canmod;
return $canmod;
}
public static function count_comments($viewids=null, $artefactids=null) {
if (!empty($viewids)) {
return get_records_sql_assoc('
......@@ -581,6 +732,9 @@ class ArtefactTypeComment extends ArtefactType {
$lastcomment = self::last_public_comment($data->view, $data->artefact);
$editableafter = time() - 60 * get_config_plugin('artefact', 'comment', 'commenteditabletime');
foreach ($data->data as &$item) {
if ($item->indent > 0) {
$item->indentwidth = 100 - $item->indent * 2;
}
$item->ts = strtotime($item->ctime);
$item->date = format_date($item->ts, 'strftimedatetime');
if ($item->ts < strtotime($item->lastcontentupdate)) {
......@@ -752,7 +906,9 @@ class ArtefactTypeComment extends ArtefactType {
$form['spam'] = array(
'secret' => get_config('formsecret'),
'mintime' => 1,
'hash' => array('authorname', 'message', 'ispublic', 'message', 'submit'),
// Not hashing the "ispublic" element, so that we can show/hide it with JS when
// doing threaded comments.
'hash' => array('authorname', 'message', 'message', 'submit'),
);
$form['elements']['authorname'] = array(
'type' => 'text',
......@@ -807,6 +963,19 @@ class ArtefactTypeComment extends ArtefactType {
'class' => 'btn-default',
'value' => array(get_string('Comment', 'artefact.comment'), get_string('cancel')),
);
// This is a placeholder where we can display the parent comment's text
// And also the strings we display when we are forcing a reply to be public or private
$snippet = smarty_core();
$form['elements']['replytoview'] = array(
'type' => 'html',
'value' => $snippet->fetch('artefact:comment:replyplaceholder.tpl')
);
// This is a placeholder for the parent comment's ID. It'll be populated by Javascript if needed.
$form['elements']['replyto'] = array(
'type' => 'hidden',
'dynamic' => 'true',
'value' => null
);
return $form;
}
......@@ -1158,6 +1327,7 @@ function delete_comment_submit(Pieform $form, $values) {
}
function add_feedback_form_validate(Pieform $form, $values) {
global $USER, $view, $artefact;
require_once(get_config('libroot') . 'antispam.php');
if ($form->get_property('spam')) {
$spamtrap = new_spam_trap(array(
......@@ -1183,6 +1353,61 @@ function add_feedback_form_validate(Pieform $form, $values) {
if ($result !== true) {
$form->set_error('message', get_string('newuserscantpostlinksorimages'));
}
if ($values['replyto']) {
$parent = get_record_sql(
'SELECT
a.id,
acc.private,
a.author,
p.author as grandparentauthor
FROM
{artefact} a
INNER JOIN {artefact_comment_comment} acc
ON a.id = acc.artefact
LEFT OUTER JOIN {artefact} p
ON a.parent = p.id
WHERE
a.id = ?
',
array($values['replyto'])
);
// Parent ID doesn't match an actual comment
if (!$parent) {
$form->set_error('message', get_string('replytonoaccess', 'artefact.comment'));
}
// Validate that you're allowed to reply to this comment
if (!empty($artefact)) {
$canedit = $USER->can_edit_artefact($artefact);
}
else {
$canedit = $USER->can_moderate_view($view);
}
// You can reply to a comment if you can see the comment. Which means if:
// 1. You are the page owner
// 2. OR the comment is public
// 3. OR the comment is a direct reply to one of your comments
if (!($canedit || !$parent->private || $parent->grandparentauthor == $USER->get('id'))) {
$form->set_error('message', get_string('replytonoaccess', 'artefact.comment'));
}
// Validate the public/private setting of this comment
if ($values['ispublic']) {
if (!ArtefactTypeComment::can_public_reply_to_comment($parent->private)) {
$form->set_error('message', get_string('replytonopublicreplyallowed', 'artefact.comment'));
}
}
else {
// You are only allowed to post a private reply if you are the page owner, or the parent comment
// is a direct reply to one of your comments
// You also cannot post a private reply to one of your own comments.
if (!ArtefactTypeComment::can_private_reply_to_comment($parent->private, $USER->get('id'), $parent->author, $parent->grandparentauthor, $artefact, $view)) {
$form->set_error('message', get_string('replytonoprivatereplyallowed', 'artefact.comment'));
}
}
}
}
function add_feedback_form_submit(Pieform $form, $values) {
......@@ -1238,6 +1463,10 @@ function add_feedback_form_submit(Pieform $form, $values) {
$data->rating = valid_rating($values['rating']);
}
if ($values['replyto']) {
$data->parent = $values['replyto'];
}
$comment = new ArtefactTypeComment(0, $data);
db_begin();
......@@ -1365,7 +1594,7 @@ function add_feedback_form_submit(Pieform $form, $values) {
db_commit();
$commentoptions = ArtefactTypeComment::get_comment_options();
$commentoptions->showcomment = 'last';
$commentoptions->showcomment = $comment->get('id');
$commentoptions->view = $view;
$commentoptions->artefact = $artefact;
$newlist = ArtefactTypeComment::get_comments($commentoptions);
......@@ -1443,6 +1672,7 @@ class ActivityTypeArtefactCommentFeedback extends ActivityTypePlugin {
// Now fetch the users that will need to get notified about this event
// depending on whether the page has an owner, group, or institution id set.
$this->users = array();
if (!empty($userid)) {
$this->users = activity_get_users($this->get_id(), array($userid));
}
......@@ -1466,11 +1696,15 @@ class ActivityTypeArtefactCommentFeedback extends ActivityTypePlugin {
// Fetch the users who will be notified because this page is on their watchlist
if (!$comment->get('private')) {
$watchlistusers = $comment->get_watchlist_users($comment->get('author'));
if (is_array($this->users)) {
$this->users = $this->users + $watchlistusers;
}
else {
$this->users = $watchlistusers;
$this->users = $this->users + $watchlistusers;
}
// If this comment is a reply, send a notification to the author of the parent comment
if ($comment->get('parent')) {
$parentauthorid = get_field('artefact', 'author', 'id', $comment->get('parent'));
if ($parentauthorid && !array_key_exists($parentauthorid, $this->users)) {
$parentauthor = get_record('usr', 'id', $parentauthorid, null, null, null, null, 'id, username, firstname, lastname, preferredname, email');
$this->users[$parentauthorid] = $parentauthor;
}
}
......
......@@ -12,5 +12,5 @@
defined('INTERNAL') || die();
$config = new StdClass;
$config->version = 2013072400;
$config->release = '0.0.3';
$config->version = 2015081000;
$config->release = '1.0.0';
......@@ -42,11 +42,14 @@ function addFeedbackSuccess(form, data) {
}
$('add_feedback_form_' + messageid).value = '';
// Clear the "Make public" switch back to its default "public" setting
$j('input#add_feedback_form_ispublic').prop('checked', true);
// need to change the watchlist link
if (data.data.updatelink) {
jQuery('#toggle_watchlist_link').text(data.data.updatelink);
}
resetFeedbackReplyto();
formSuccess(form, data);
// Check if the form is displayed inside a modal
......@@ -66,6 +69,13 @@ function objectionSuccess(form, data) {
}
}
function resetFeedbackReplyto() {
$j('#comment_reply_parent').hide();
$j('#add_feedback_form_replyto').val('');
$j('#add_feedback_form_ispublic_container .form-switch').show().removeClass('hidden');
$j('#add_feedback_form_ispublic_container .add_feedback_form_privacy_message').remove();
}
function isTinyMceUsed() {
return (typeof(tinyMCE) != 'undefined' && typeof(tinyMCE.get('add_feedback_form_message')) != 'undefined');
}
......@@ -117,4 +127,61 @@ jQuery(function($j) {
}
});
});
// Set up the onclick method for all comment reply buttons
$j('.feedbacktable').on('click', '.commentreplyto', null, function(e){
e.preventDefault();
// Each comment stores its ID as a "replyto" data attribute
var replyto = $j(e.target).data('replyto');
var canpublicreply = $j(e.target).data('canpublicreply');
var canprivatereply = $j(e.target).data('canprivatereply');
if (replyto) {
// Put this comment's ID in the "replyto" hidden form field
$j('#add_feedback_form_replyto').val(replyto);
var replyview = $j('#comment_reply_parent');
// Remove any previous "reply to" comment that was being displayed
replyview.find('div').remove();
// Display a copy of this comment below the feedback form
var commentcopy = $j('#comment' + replyto).clone();
// Disable the action buttons from the display copy
commentcopy.find('.comment-item-buttons').remove();
commentcopy.appendTo(replyview);
replyview.show().removeClass('hidden');
// Check whether we need to force a "private" or "public" message
// (This is only for display. We'll also check & enforce this on the server side.)
var makepublicswitch = $j('#add_feedback_form_ispublic_container .form-switch');
$j('#add_feedback_form_ispublic_container .add_feedback_form_privacy_message').remove();
if (canpublicreply && canprivatereply) {
// If they have both options, show the normal switch
makepublicswitch.show().removeClass('hidden');
}
else {
makepublicswitch.hide();
var msg = null;
// They can only post a public reply
if (!canprivatereply) {
makepublicswitch.find("input#add_feedback_form_ispublic").prop('checked', true);
msg = $j(".add_feedback_form_forcepublic_message").clone().show().removeClass("hidden");
}
// They can only post a private reply
else {
makepublicswitch.find("input#add_feedback_form_ispublic").prop('checked', false);
msg = $j(".add_feedback_form_forceprivate_message").clone().show().removeClass("hidden");
}
$j('#add_feedback_form_ispublic_container').append(msg);
}
}
// Open the comment feedback form (as if you clicked on it)
$j('#add_feedback_link a').focus();
$j('#add_feedback_link a').click();
jQuery('html, body').animate({ scrollTop: jQuery('#add_feedback_link').offset().top }, 'fast');
return false;
});
});
......@@ -335,6 +335,8 @@ $string['dropdownmenudescription1'] = 'If set to "On", the main Mahara navigatio
$string['dropdownmenudescriptioninstitution1'] = 'If set to "On", the main navigation will use a drop-down menu.';
$string['commentsortorder'] = 'Comment sort order';
$string['commentsortorderdescription'] = 'Set the sort order for artefact comments when viewed on a page.';
$string['commentthreaded'] = 'Threaded comments';
$string['commentthreadeddescription'] = 'Allows threaded replies to individual comments on a page.';
$string['defaultaccountinactiveexpire'] = 'Default account inactivity time';
$string['defaultaccountinactiveexpiredescription'] = 'How long a user account will remain active without the user logging in';
$string['defaultaccountinactivewarn'] = 'Warning time for inactivity / expiry';
......
......@@ -147,7 +147,7 @@ function activity_get_users($activitytype, $userids=null, $userobjs=null, $admin
} else if ($adminonly) {
$sql .= ' AND u.admin = 1';
}
return get_records_sql_array($sql, $values);
return get_records_sql_assoc($sql, $values);
}
......