<?php
// Copyright 2011 Toby Zerner, Simon Zerner
// This file is part of esoTalk. Please see the included license file for usage information.
if (!defined("IN_ESOTALK")) exit;
/**
* The conversation controller handles all actions to do with viewing/managing a single conversation.
*
* @package esoTalk
*/
class ETConversationController extends ETController {
/**
* Show a full conversation.
*
* @param string $conversationId The conversation ID, suffixed with the conversation's slug.
* @param mixed $year Can be in one of three formats:
* YYYY/MM: start viewing posts from a certain year/month combination
* pX: start viewing posts from page X
* X: start viewing posts from position X
* @param int $month If specified, the YYYY/MM combination will be used.
* @return void
*/
public function action_index($conversationId = false, $year = false, $month = false)
{
if (!$this->allowed()) return;
// Get the conversation.
$conversation = ET::conversationModel()->getById((int)$conversationId);
// Stop here with a 404 header if the conversation wasn't found.
if (!$conversation) {
$this->render404(T("message.conversationNotFound"), true);
return false;
}
// Are we searching within the conversation? If so, set the searchString and set the number of results as the post count.
$searchString = R("search");
if ($searchString) {
$conversation["countPosts"] = ET::postModel()->getSearchResultsCount($conversation["conversationId"], $searchString);
$conversation["searching"] = true;
// Add the keywords in $this->searchString to be highlighted. Make sure we keep ones "in quotes" together.
$words = array();
$term = $searchString;
if (preg_match_all('/"(.+?)"/', $term, $matches)) {
$words[] = $matches[1];
$term = preg_replace('/".+?"/', '', $term);
}
$words = array_unique(array_merge($words, explode(" ", $term)));
ET::$session->store("highlight", $words);
}
// If we're not searching, clear the highlighted words.
else {
ET::$session->remove("highlight");
}
// Work out which post we are viewing from.
$startFrom = 0;
if ($year) {
// Redirect to the user's oldest unread post.
if ($year == "unread") {
// Fetch the post ID of the user's oldest unread post (according to $conversation["lastRead"].)
$id = ET::SQL()
->select("postId")
->from("post")
->where("conversationId=:conversationId")->bind(":conversationId", $conversation["conversationId"])
->orderBy("time ASC")
->offset((int)$conversation["lastRead"])
->limit(1)
->exec()
->result();
// If a post ID was found, redirect to its position within the conversation.
$startFrom = max(0, min($conversation["lastRead"], $conversation["countPosts"] - C("esoTalk.conversation.postsPerPage")));
if ($id) $this->redirect(URL(conversationURL($conversation["conversationId"], $conversation["title"])."/$startFrom#p$id"));
}
// Redirect to the last post in the conversation.
if ($year == "unread" or $year == "last") {
// Fetch the post ID of the last post in the conversation.
$id = ET::SQL()
->select("postId")
->from("post")
->where("conversationId=:conversationId")->bind(":conversationId", $conversation["conversationId"])
->orderBy("time DESC")
->limit(1)
->exec()
->result();
// Redirect there.
$startFrom = max(0, $conversation["countPosts"] - C("esoTalk.conversation.postsPerPage"));
$this->redirect(URL(conversationURL($conversation["conversationId"], $conversation["title"])."/$startFrom#p$id"));
}
// If a month was specified, interpret the arguments as year/month.
elseif ($month and !$searchString) {
$year = (int)$year;
$month = (int)$month;
// Bit of a hacky way of loading posts from the last page.
if ($year == 9999 and $month == 99) $timestamp = PHP_INT_MAX;
// Make a timestamp out of this date.
else $timestamp = mktime(0, 0, 0, min($month, 12), 1, min($year, 2038));
// Find the closest post that's after this timestamp, and find its position within the conversation.
$position = ET::SQL()
->select("COUNT(postId)", "position")
->from("post")
->where("time < :time")->bind(":time", $timestamp)
->where("conversationId = :conversationId")->bind(":conversationId", $conversation["conversationId"])
->exec()
->result();
$startFrom = min($conversation["countPosts"] - C("esoTalk.conversation.postsPerPage"), $position);
$this->data("month", $month);
$this->data("year", $year);
}
// Otherwise, interpret it is a plain page number, or position.
else {
if ($year[0] == "p") $startFrom = ((int)ltrim($year, "p") - 1) * C("esoTalk.conversation.postsPerPage");
else $startFrom = (int)$year;
}
}
// Make sure the startFrom number is within range.
$startFrom = max(0, $startFrom);
if ($this->responseType === RESPONSE_TYPE_DEFAULT) $startFrom = min($startFrom, $conversation["countPosts"] - 1);
if (ET::$session->userId) {
// Update the user's last read.
ET::conversationModel()->setLastRead($conversation, ET::$session->userId, $startFrom + C("esoTalk.conversation.postsPerPage"));
// If we're on the last page, mark any notifications related to this conversation as read.
if ($startFrom + C("esoTalk.conversation.postsPerPage") >= $conversation["countPosts"]) {
ET::activityModel()->markNotificationsAsRead(null, $conversation["conversationId"]);
}
// Update the user's last action.
ET::memberModel()->updateLastAction("viewingConversation", $conversation["private"] ? null : array(
"conversationId" => $conversation["conversationId"],
"title" => $conversation["title"]
));
}
// Get the posts in the conversation.
$options = array(
"startFrom" => $startFrom,
"limit" => C("esoTalk.conversation.postsPerPage")
);
if ($searchString) $options["search"] = $searchString;
if ($startFrom < $conversation["countPosts"]) $posts = ET::postModel()->getByConversation($conversation["conversationId"], $options);
else $posts = array();
$this->trigger("conversationIndex", array(&$conversation, &$posts, &$startFrom, &$searchString));
// Transport some data to the view.
$this->data("conversation", $conversation);
$this->data("posts", $posts);
$this->data("startFrom", $startFrom);
$this->data("searchString", $searchString);
if ($this->responseType === RESPONSE_TYPE_DEFAULT) {
// Construct a canonical URL to this page.
$url = conversationURL($conversation["conversationId"], $conversation["title"])."/$startFrom".($searchString ? "?search=".urlencode($searchString) : "");
$this->canonicalURL = URL($url, true);
// If the slug in the URL is not the same as the actual slug, redirect.
$slug = slug($conversation["title"]);
if ($slug and (strpos($conversationId, "-") === false or substr($conversationId, strpos($conversationId, "-") + 1) != $slug)) {
redirect(URL($url), 301);
}
// Push onto the top of the naviagation stack.
$this->pushNavigation("conversation/".$conversation["conversationId"], "conversation", URL($url));
// Set the title of the page.
$this->title = $conversation["title"];
// Get a list of the members allowed in this conversation.
$conversation["membersAllowed"] = ET::conversationModel()->getMembersAllowed($conversation);
$conversation["membersAllowedSummary"] = ET::conversationModel()->getMembersAllowedSummary($conversation, $conversation["membersAllowed"]);
// Get the channel path of this conversation.
$conversation["channelPath"] = ET::conversationModel()->getChannelPath($conversation);
// Add essential variables and language definitions to be accessible through JavaScript.
if ($conversation["canModerate"]) {
$this->addJSLanguage("Lock", "Unlock", "Sticky", "Unsticky");
}
if ($conversation["canDeleteConversation"]) {
$this->addJSLanguage("message.confirmDelete");
}
if (ET::$session->user) {
$this->addJSLanguage("Starred", "Unstarred", "message.confirmLeave", "message.confirmDiscardPost",
"Ignore conversation", "Unignore conversation", "Controls", "Follow", "Following");
}
$this->addJSVar("postsPerPage", C("esoTalk.conversation.postsPerPage"));
$this->addJSVar("conversationUpdateIntervalStart", C("esoTalk.conversation.updateIntervalStart"));
$this->addJSVar("conversationUpdateIntervalMultiplier", C("esoTalk.conversation.updateIntervalMultiplier"));
$this->addJSVar("conversationUpdateIntervalLimit", C("esoTalk.conversation.updateIntervalLimit"));
$this->addJSVar("mentions", C("esoTalk.format.mentions"));
$this->addJSVar("time", time());
$this->addJSFile("core/js/lib/jquery.autogrow.js");
$this->addJSFile("core/js/scrubber.js");
$this->addJSFile("core/js/autocomplete.js");
$this->addJSFile("core/js/conversation.js");
// Add the RSS feed button.
// $this->addToMenu("meta", "feed", "<a href='".URL("conversation/index.atom/".$url)."' id='feed'>".T("Feed")."</a>");
$controls = ETFactory::make("menu");
// Ignore conversation control
if (ET::$session->user) {
$controls->add("ignore", "<a href='".URL("conversation/ignore/".$conversation["conversationId"]."/?token=".ET::$session->token."&return=".urlencode($this->selfURL))."' id='control-ignore'><i class='icon-eye-close'></i> <span>".T($conversation["ignored"] ? "Unignore conversation" : "Ignore conversation")."</span></a>");
}
// Mark as unread/read control
if (ET::$session->user) {
$controls->add("read", "<a href='".URL("conversation/read/".$conversation["conversationId"]."/?token=".ET::$session->token)."' id='control-read'><i class='icon-circle'></i> <span>".T($conversation["lastRead"] >= $conversation["countPosts"] ? "Mark as unread" : "Mark as read")."</span></a>");
}
if ($conversation["canModerate"] or $conversation["startMemberId"] == ET::$session->userId) {
$controls->separator();
// Add the change channel control.
$controls->add("changeChannel", "<a href='".URL("conversation/changeChannel/".$conversation["conversationId"]."/?return=".urlencode($this->selfURL))."' id='control-changeChannel'><i class='icon-tag'></i> <span>".T("Change channel")."</span></a>");
}
// If the user has permission to moderate this conversation...
if ($conversation["canModerate"]) {
// Add the sticky/unsticky control.
$controls->add("sticky", "<a href='".URL("conversation/sticky/".$conversation["conversationId"]."/?token=".ET::$session->token."&return=".urlencode($this->selfURL))."' id='control-sticky'><i class='icon-pushpin'></i> <span>".T($conversation["sticky"] ? "Unsticky" : "Sticky")."</span></a>");
// Add the lock/unlock control.
$controls->add("lock", "<a href='".URL("conversation/lock/".$conversation["conversationId"]."/?token=".ET::$session->token."&return=".urlencode($this->selfURL))."' id='control-lock'><i class='icon-lock'></i> <span>".T($conversation["locked"] ? "Unlock" : "Lock")."</span></a>");
}
if ($conversation["canDeleteConversation"]) {
// Add the delete conversation control.
$controls->separator();
$controls->add("delete", "<a href='".URL("conversation/delete/".$conversation["conversationId"]."/?token=".ET::$session->token)."' id='control-delete'><i class='icon-remove'></i> <span>".T("Delete conversation")."</span></a>");
}
// Add the meta description tag to the head. It will contain an excerpt from the first post's content.
if ($conversation["countPosts"] > 0) {
$description = ET::SQL()
->select("LEFT(content, 156)")
->from("post")
->where("conversationId=:conversationId")
->bind(":conversationId", $conversation["conversationId"])
->orderBy("time ASC")
->limit(1)
->exec()
->result();
if (strlen($description) > 155) $description = substr($description, 0, strrpos($description, " ")) . " ...";
$description = str_replace(array("\n\n", "\n"), " ", $description);
$this->addToHead("<meta name='description' content='".sanitizeHTML($description)."'>");
}
// Add JavaScript variables which contain conversation information.
$this->addJSVar("conversation", array(
"conversationId" => (int)$conversation["conversationId"],
"slug" => conversationURL($conversation["conversationId"], $conversation["title"]),
"countPosts" => (int)$conversation["countPosts"],
"startFrom" => (int)$startFrom,
"searchString" => $searchString,
"lastRead" => (ET::$session->user and $conversation["conversationId"])
? (int)max(0, min($conversation["countPosts"], $conversation["lastRead"]))
: (int)$conversation["countPosts"],
// Start the auto-reload interval at the square root of the number of seconds since the last action.
"updateInterval" => max(C("esoTalk.conversation.updateIntervalStart"), min(round(sqrt(time() - $conversation["lastPostTime"])), C("esoTalk.conversation.updateIntervalLimit"))),
"channelId" => (int)$conversation["channelId"],
));
// Quote a post: get the post details (id, name, content) and then set the value of the reply textarea appropriately.
if ($postId = (int)R("quote")) {
$post = $this->getPostForQuoting($postId, $conversation["conversationId"]);
if ($post) $conversation["draft"] = "[quote=$postId:".$post["username"]."]".ET::formatter()->init($post["content"])->removeQuotes()->get()."[/quote]";
}
// Set up the reply form.
$replyForm = ETFactory::make("form");
$replyForm->action = URL("conversation/reply/".$conversation["conversationId"]);
$replyForm->setValue("content", $conversation["draft"]);
$this->trigger("conversationIndexDefault", array(&$conversation, &$controls, &$replyForm, &$replyControls));
$this->data("replyForm", $replyForm);
$this->data("replyControls", $this->getEditControls("reply"));
$this->data("conversation", $conversation);
$this->data("controlsMenu", $controls);
$this->render("conversation/index");
}
elseif ($this->responseType === RESPONSE_TYPE_AJAX) {
$this->json("countPosts", $conversation["countPosts"]);
$this->json("startFrom", $startFrom);
$this->render("conversation/posts");
}
elseif ($this->responseType === RESPONSE_TYPE_VIEW) {
$this->render("conversation/posts");
}
}
/**
* Show the start conversation page.
*
* @param string $member A member's name to make the conversation private with.
* @return void
*/
public function action_start($member = false)
{
// If the user isn't logged in, redirect them to the login page.
if (!ET::$session->user) $this->redirect(URL("user/login?return=conversation/start"));
// If the user is suspended, show an error.
if (ET::$session->isSuspended()) {
$this->renderMessage("Error!", T("message.suspended"));
return;
}
// Set up a form.
$form = ETFactory::make("form");
$form->action = URL("conversation/start");
// Get a list of channels so that we can check to make sure a valid channel is selected.
$channels = ET::channelModel()->get("start");
$channelId = $form->validPostBack("content") ? ET::$session->get("channelId") : ET::$session->get("searchChannelId");
ET::$session->store("channelId", isset($channels[$channelId]) ? $channelId : reset(array_keys($channels)));
// Get an empty conversation.
$model = ET::conversationModel();
$conversation = $model->getEmptyConversation();
$conversation["membersAllowed"] = $model->getMembersAllowed($conversation);
$conversation["membersAllowedSummary"] = $model->getMembersAllowedSummary($conversation, $conversation["membersAllowed"]);
$conversation["channelPath"] = $model->getChannelPath($conversation);
if ($this->responseType === RESPONSE_TYPE_DEFAULT) {
$this->title = T("New conversation");
// Update the user's last action to say that they're "starting a conversation".
ET::memberModel()->updateLastAction("startingConversation");
// Add a meta tag to the head to prevent search engines from indexing this page.
$this->addToHead("<meta name='robots' content='noindex, noarchive'/>");
$this->addJSFile("core/js/lib/jquery.autogrow.js");
$this->addJSFile("core/js/scrubber.js");
$this->addJSFile("core/js/autocomplete.js");
$this->addJSFile("core/js/conversation.js");
$this->addJSVar("mentions", C("esoTalk.format.mentions"));
$this->addJSLanguage("message.confirmLeave", "message.confirmDiscardPost");
// If there's a member name in the querystring, make the conversation that we're starting private
// with them and redirect.
if ($member and ET::$session->validateToken(R("token"))) {
ET::$session->remove("membersAllowed");
if (!($member = ET::conversationModel()->getMemberFromName($member))) {
$this->message(T("message.memberDoesntExist"), "warning");
}
else {
ET::conversationModel()->addMember($conversation, $member);
}
$this->redirect(URL("conversation/start"));
}
}
// If the form was submitted (validate the presence of the content field)...
if ($form->validPostBack("content")) {
$model = ET::conversationModel();
$result = $model->create(array(
"title" => $_POST["title"],
"channelId" => ET::$session->get("channelId"),
"content" => $_POST["content"],
), ET::$session->get("membersAllowed"), $form->isPostBack("saveDraft"));
if ($model->errorCount()) {
$this->messages($model->errors(), "warning");
}
if ($result) {
list($conversationId, $postId) = $result;
ET::$session->remove("membersAllowed");
ET::$session->remove("channelId");
if ($this->responseType === RESPONSE_TYPE_JSON) {
$this->json("url", URL(conversationURL($conversationId, $form->getValue("title"))));
$this->json("conversationId", $conversationId);
}
else $this->redirect(URL(conversationURL($conversationId, $form->getValue("title"))));
}
}
// Make a form to add members allowed.
$membersAllowedForm = ETFactory::make("form");
$membersAllowedForm->action = URL("conversation/addMember/");
$this->data("conversation", $conversation);
$this->data("form", $form);
$this->data("membersAllowedForm", $membersAllowedForm);
$this->data("replyControls", $this->getEditControls("reply"));
$this->render("conversation/edit");
}
/**
* Redirect to show a specific post within its conversation.
*
* @param int $postId The post ID to show.
* @return void
*/
public function action_post($postId = false)
{
// Construct a subquery that will find the position of a post within its conversation.
$subquery = ET::SQL()
->select("COUNT(postId)")
->from("post p2")
->where("p2.conversationId=p.conversationId")
->where("p2.time<=p.time")
->where("IF(p2.time=p.time,p2.postId<p.postId,1)")
->get();
// Construct and run a query that will get the position of the post, the conversation ID, and the title.
$result = ET::SQL()
->select("($subquery) AS pos, c.conversationId, c.title")
->from("post p")
->from("conversation c", "c.conversationId=p.conversationId", "left")
->where("p.postId=:postId")
->bind(":postId", (int)$postId)
->exec();
// If the post wasn't found, show a 404.
if (!$result->numRows()) {
$this->render404(T("message.postNotFound"));
return;
}
list($pos, $conversationId, $title) = array_values($result->firstRow());
// Work out which page of the conversation this post is on, and redirect there.
$page = floor($pos / C("esoTalk.conversation.postsPerPage")) + 1;
$this->redirect(URL(conversationURL($conversationId, $title)."/p".$page."#p".$postId));
}
/**
* Show a post's details in JSON format so they can be used to construct a quote. The JSON output will
* include the postId, member (prefixed with an @ if mentions are enabled), and the content (with inner quotes
* removed.)
*
* @param int $postId The post ID.
* @return void
*/
public function action_quotePost($postId = false)
{
$this->responseType = RESPONSE_TYPE_JSON;
// Fetch the conversation to make sure the user is allowed to view this conversation.
$conversation = ET::conversationModel()->getByPostId($postId);
// Stop here if the conversation doesn't exist, or if the user is not allowed to view it.
if (!$conversation) {
$this->render404(T("message.conversationNotFound"));
return;
}
$post = $this->getPostForQuoting($postId, $conversation["conversationId"]);
if ($post) {
$this->json("postId", $postId);
$this->json("member", (C("esoTalk.format.mentions") ? "@" : "").$post["username"]);
$this->json("content", ET::formatter()->init($post["content"], false)->removeQuotes()->get());
$this->render();
}
}
/**
* Delete a conversation, and redirect to the home page.
*
* @param int $conversationId The ID of the conversation to delete.
* @return void
*/
public function action_delete($conversationId = false)
{
if (!$this->validateToken()) return;
if (!($conversation = $this->getConversation($conversationId))) return;
// Do we have permission to do this?
if (!$conversation["canDeleteConversation"]) {
$this->renderMessage(T("Error"), T("message.noPermission"));
return;
}
// Delete the conversation, then redirect to the index.
ET::conversationModel()->deleteById($conversation["conversationId"]);
$this->message(T("message.conversationDeleted"), "success autoDismiss");
$this->redirect(URL(""));
}
/**
* Discard a draft.
*
* @param int $conversationId The ID of the conversation to discard the draft for.
* @return void
*/
public function action_discard($conversationId = false)
{
if (!$this->validateToken()) return;
$conversation = ET::conversationModel()->getById($conversationId) ?: ET::conversationModel()->getEmptyConversation();
ET::conversationModel()->setDraft($conversation, ET::$session->userId, null);
// If there are no other posts in the conversation, delete the conversation.
if (!$conversation["countPosts"]) {
if ($conversation["conversationId"]) $this->action_delete($conversation["conversationId"]);
else $this->redirect(URL(""));
return;
}
// For an AJAX request, add the conversation labels to the output.
if ($this->responseType === RESPONSE_TYPE_AJAX) {
$this->json("labels", $this->getViewContents("conversation/labels", array("labels" => $conversation["labels"])));
$this->render();
return;
}
}
/**
* Toggle the sticky flag on a conversation.
*
* @param int $conversationId The ID of the conversation.
* @return void
*/
public function action_sticky($conversationId = false)
{
$this->toggle($conversationId, "sticky");
}
/**
* Toggle the locked flag on a conversation.
*
* @param int $conversationId The ID of the conversation.
* @return void
*/
public function action_lock($conversationId = false)
{
$this->toggle($conversationId, "locked");
}
/**
* Toggle a flag on a conversation.
*
* @param int $conversationId The ID of the conversation.
* @param string $type The name of the flag to toggle.
* @return void
*/
public function toggle($conversationId, $type)
{
if (!$this->validateToken()) return;
if (!($conversation = $this->getConversation($conversationId))) return;
// Do we have permission to do this?
if (!$conversation["canModerate"]) {
$this->renderMessage(T("Error"), T("message.noPermission"));
return;
}
$function = "set".ucfirst($type);
ET::conversationModel()->$function($conversation, !$conversation[$type]);
// For the default response type, redirect back to the conversation.
if ($this->responseType === RESPONSE_TYPE_DEFAULT) {
$this->redirect(URL(R("return", conversationURL($conversation["id"], $conversation["title"]))));
}
// Otherwise, output JSON of the flag's new value.
else {
$this->json($type, !$conversation[$type]);
if ($this->responseType === RESPONSE_TYPE_AJAX)
$this->json("labels", $this->getViewContents("conversation/labels", array("labels" => $conversation["labels"])));
$this->render();
}
}
/**
* Show a page where a conversation's details (title, members allowed) can be edited.
*
* @param int $conversationId The ID of the conversation to edit.
* @return void
*/
public function action_edit($conversationId = false)
{
if (!($conversation = $this->getConversation($conversationId))) return;
// Do we have permission to do this?
if (!$conversation["canModerate"] and $conversation["startMemberId"] != ET::$session->userId) {
$this->renderMessage(T("Error"), T("message.noPermission"));
return;
}
// Make a form to submit to the save page.
$form = ETFactory::make("form");
$form->action = URL("conversation/save/".$conversation["conversationId"]);
$form->setValue("title", $conversation["title"]);
// Get a list of the members allowed in this conversation.
$conversation["membersAllowed"] = ET::conversationModel()->getMembersAllowed($conversation);
$conversation["membersAllowedSummary"] = ET::conversationModel()->getMembersAllowedSummary($conversation, $conversation["membersAllowed"]);
$conversation["channelPath"] = ET::conversationModel()->getChannelPath($conversation);
// Make a form to add members allowed.
$membersAllowedForm = ETFactory::make("form");
$membersAllowedForm->action = URL("conversation/addMember/".$conversation["conversationId"]);
// Pass along the data to the view.
$this->data("conversation", $conversation);
$this->data("form", $form);
$this->data("membersAllowedForm", $membersAllowedForm);
$this->render("conversation/edit");
}
/**
* Show a page where a conversation's channel can be changed.
*
* @param int $conversationId The ID of the conversation to edit.
* @return void
*/
public function action_changeChannel($conversationId = "")
{
// Get the conversation.
if (!$conversationId) $conversation = ET::conversationModel()->getEmptyConversation();
elseif (!($conversation = $this->getConversation($conversationId))) return;
// Do we have permission to do this?
if (!$conversation["canModerate"] and $conversation["startMemberId"] != ET::$session->userId) {
$this->renderMessage(T("Error"), T("message.noPermission"));
return;
}
// Get the channels, and add a "start" permission field to each of them.
$channels = ET::channelModel()->get();
$groupModel = ET::groupModel();
$groupIds = ET::$session->getGroupIds();
foreach ($channels as $k => &$channel) {
if (!empty($channel["unsubscribed"])) {
unset($channels[$k]);
continue;
}
$channel["start"] = $groupModel->groupIdsAllowedInGroupIds($groupIds, $channel["permissions"]["start"], true);
}
// Make a form to submit to the save page.
$form = ETFactory::make("form");
$form->action = URL("conversation/save/".$conversation["conversationId"]);
$form->setValue("channel", $conversation["channelId"]);
// Pass along data to the view.
$this->data("conversation", $conversation);
$this->data("channels", $channels);
$this->data("form", $form);
$this->render("conversation/changeChannel");
}
/**
* Save a conversation's details.
*
* @param int $conversationId The conversation ID.
* @return void
*/
public function action_save($conversationId = false)
{
if (!$this->validateToken()) return;
// Get the conversation.
$model = ET::conversationModel();
if (!$conversationId) $conversation = $model->getEmptyConversation();
elseif (!($conversation = $this->getConversation($conversationId))) return;
// Do we have permission to do this?
if (!$conversation["canModerate"] and $conversation["startMemberId"] != ET::$session->userId) {
$this->renderMessage(T("Error"), T("message.noPermission"));
return;
}
// Set up a form to handle input.
$form = ETFactory::make("form");
// If the conversation exists, interact with the conversation model to save data.
if ($conversation["conversationId"]) {
// Save the title.
if ($title = $form->getValue("title"))
$model->setTitle($conversation, $title);
// Save the channel.
if ($channelId = $form->getValue("channel"))
$model->setChannel($conversation, $channelId);
// If there are errors, show them.
if ($model->errorCount())
$this->messages($model->errors(), "warning");
// Otherwise, redirect to the conversation.
elseif ($this->responseType === RESPONSE_TYPE_DEFAULT)
redirect(URL(R("return", conversationURL($conversation["conversationId"], $conversation["title"]))));
// Fetch the new conversation details.
$conversation = $model->getById($conversation["conversationId"]);
}
// If the conversation does not exist (i.e. we're changing the channel when starting a conversation),
// interact with the session channelId variable.
else {
if ($channelId = $form->getValue("channel"))
ET::$session->store("channelId", (int)$channelId);
// If there are errors, show them.
if ($model->errorCount())
$this->messages($model->errors(), "warning");
// Otherwise, redirect to the start conversation page.
elseif ($this->responseType === RESPONSE_TYPE_DEFAULT)
redirect(URL(R("return", "conversation/start")));
// Fetch the new conversation details.
$conversation = $model->getEmptyConversation();
}
// As the channel may have been changed, we need to fetch the members allowed summary (as it could vary
// depending on what groups have permission to view the channel.)
$conversation["membersAllowed"] = $model->getMembersAllowed($conversation);
$conversation["membersAllowedSummary"] = $model->getMembersAllowedSummary($conversation, $conversation["membersAllowed"]);
$conversation["channelPath"] = $model->getChannelPath($conversation);
$this->json("allowedSummary", $this->getViewContents("conversation/membersAllowedSummary", array("conversation" => $conversation)));
$this->json("channelPath", $this->getViewContents("conversation/channelPath", array("conversation" => $conversation)));
// Also return the details of the new channel.
$this->json("channel", array(
"channelId" => $conversation["channelId"],
"link" => URL("conversations/".$conversation["channelSlug"]),
"title" => $conversation["channelTitle"],
"description" => $conversation["channelDescription"]
));
$this->render();
}
/**
* Show a page where the members allowed in a conversation can be edited.
*
* @param int $conversationId The ID of the conversation to edit.
* @return void
*/
public function action_membersAllowed($conversationId = false)
{
// Get the conversation.
if (!$conversationId) $conversation = ET::conversationModel()->getEmptyConversation();
elseif (!($conversation = $this->getConversation($conversationId))) return;
// Do we have permission to do this?
if (!$conversation["canModerate"] and ET::$session->userId != $conversation["startMemberId"]) {
$this->renderMessage(T("Error"), T("message.noPermission"));
return;
}
$conversation["membersAllowed"] = ET::conversationModel()->getMembersAllowed($conversation);
$conversation["membersAllowedSummary"] = ET::conversationModel()->getMembersAllowedSummary($conversation, $conversation["membersAllowed"]);
// Make a form to add members allowed.
$form = ETFactory::make("form");
$form->action = URL("conversation/addMember/".$conversation["conversationId"]);
$this->data("conversation", $conversation);
$this->data("form", $form);
$this->render("conversation/editMembersAllowed");
}
/**
* Show a full list of the members allowed in a conversation. This is used in popups triggered by hovering
* over a "3 others" link or a private label.
*
* @param int $conversationId The ID of the conversation to get members allowed for.
* @return void
*/
public function action_membersAllowedList($conversationId = false)
{
// Get the conversation.
if (!$conversationId) $conversation = ET::conversationModel()->getEmptyConversation();
elseif (!($conversation = $this->getConversation($conversationId))) return;
$conversation["membersAllowed"] = ET::conversationModel()->getMembersAllowed($conversation);
$conversation["membersAllowed"] = ET::conversationModel()->getMembersAllowedSummary($conversation, $conversation["membersAllowed"]);
$this->data("conversation", $conversation);
$this->render("conversation/membersAllowedList");
}
/**
* Add a member to the allowed list of a conversation.
*
* @param int $conversationId The ID of the conversation.
* @return void
*/
public function action_addMember($conversationId = false)
{
if (!$this->validateToken()) return;
// Get the conversation.
$model = ET::conversationModel();
if (!$conversationId) $conversation = $model->getEmptyConversation();
elseif (!($conversation = $this->getConversation($conversationId))) return;
if ($name = str_replace("\xc2\xa0", " ", R("member"))) {
// Get an entity's details by parsing the member name.
if (!($member = $model->getMemberFromName($name))) {
$this->message(T("message.memberNotFound"), array("className" => "warning autoDismiss", "id" => "memberNotFound"));
}
// Make sure the entity is allowed to view the channel that the conversation is in.
elseif (!ET::groupModel()->groupIdsAllowedInGroupIds($member["type"] == "group" ? $member["id"] : $member["groups"], array_keys($conversation["channelPermissionView"]))) {
$this->message(T("message.memberNoPermissionView"), "warning");
}
// Good to go? Add the member!
else {
$model->addMember($conversation, $member);
}
}
// Fetch the new list of members allowed in the conversation.
$conversation["membersAllowed"] = $model->getMembersAllowed($conversation);
$conversation["membersAllowedSummary"] = $model->getMembersAllowedSummary($conversation, $conversation["membersAllowed"]);
// If it's an AJAX request, return the contents of a few views.
if ($this->responseType === RESPONSE_TYPE_AJAX) {
$this->json("allowedSummary", $this->getViewContents("conversation/membersAllowedSummary", array("conversation" => $conversation)));
$this->json("allowedList", $this->getViewContents("conversation/membersAllowedList", array("conversation" => $conversation, "editable" => true)));
$this->json("labels", $this->getViewContents("conversation/labels", array("labels" => $conversation["labels"])));
$this->render();
}
// JSON?
// Otherwise, redirect back to the conversation edit page.
else {
$this->redirect(URL(R("return", $conversation["conversationId"] ? "conversation/edit/".$conversation["conversationId"] : "conversation/start")));
}
}
/**
* Remove a member from the allowed list of a conversation.
*
* @param int $conversationId The ID of the conversation.
* @return void
*/
public function action_removeMember($conversationId = false)
{
if (!$this->validateToken()) return;
// Get the conversation.
$model = ET::conversationModel();
if (!$conversationId) $conversation = $model->getEmptyConversation();
elseif (!($conversation = $this->getConversation($conversationId))) return;
// Get the members allowed in the conversation.
$conversation["membersAllowed"] = $model->getMembersAllowed($conversation);
$member = null;
// We could be removing a member...
if ($id = R("member")) {
$member = array("type" => "member", "id" => $id);
}
// Or we could be removing a group.
elseif ($id = R("group")) {
$member = array("type" => "group", "id" => $id);
}
// If we have a member/group to remove, remove it!
if ($member) {
$model->removeMember($conversation, $member);
}
// Now grab the new members allowed summary for the conversation.
$conversation["membersAllowedSummary"] = $model->getMembersAllowedSummary($conversation, $conversation["membersAllowed"]);
// If it's an AJAX request, return the contents of a few views.
if ($this->responseType === RESPONSE_TYPE_AJAX) {
$this->json("allowedSummary", $this->getViewContents("conversation/membersAllowedSummary", array("conversation" => $conversation)));
$this->json("allowedList", $this->getViewContents("conversation/membersAllowedList", array("conversation" => $conversation, "editable" => true)));
$this->json("labels", $this->getViewContents("conversation/labels", array("labels" => $conversation["labels"])));
$this->render();
}
// JSON?
// Otherwise, redirect back to the conversation edit page.
else {
$this->redirect(URL(R("return", $conversation["conversationId"] ? "conversation/edit/".$conversation["conversationId"] : "conversation/start")));
}
}
/**
* Toggle the starred flag of a conversation for the current user.
*
* @param int $conversationId The ID of the conversation.
* @return void
*/
public function action_star($conversationId = false)
{
if (!ET::$session->user or !$this->validateToken()) return;
// Get the conversation.
if (!($conversation = $this->getConversation($conversationId))) return;
// Star/unstar the conversation.
$starred = !$conversation["starred"];
ET::conversationModel()->setStatus($conversation["conversationId"], ET::$session->userId, array("starred" => $starred));
$this->json("starred", $starred);
// Redirect back to the conversation.
if ($this->responseType === RESPONSE_TYPE_DEFAULT) {
redirect(URL(R("return", conversationURL($conversation["conversationId"], $conversation["title"]))));
}
$this->render();
}
/**
* Toggle the ignored flag of a conversation for the current user.
*
* @param int $conversationId The ID of the conversation.
* @return void
*/
public function action_ignore($conversationId = false)
{
if (!ET::$session->user or !$this->validateToken()) return;
// Get the conversation.
if (!($conversation = $this->getConversation($conversationId))) return;
// Ignore/unignore the conversation.
$ignored = !$conversation["ignored"];
ET::conversationModel()->setIgnored($conversation, ET::$session->userId, $ignored);
$this->json("ignored", $ignored);
// Redirect back to the conversation.
if ($this->responseType === RESPONSE_TYPE_DEFAULT) {
redirect(URL(R("return", conversationURL($conversation["conversationId"], $conversation["title"]))));
}
// If it's an AJAX request, return the contents of the labels view.
elseif ($this->responseType === RESPONSE_TYPE_AJAX)
$this->json("labels", $this->getViewContents("conversation/labels", array("labels" => $conversation["labels"])));
$this->render();
}
/**
* Toggle the conversation between the read/unread states.
*
* @param int $conversationId The ID of the conversation.
* @return void
*/
public function action_read($conversationId = false)
{
if (!ET::$session->user or !$this->validateToken()) return;
// Get the conversation.
if (!($conversation = $this->getConversation($conversationId))) return;
if ($conversation["lastRead"] >= $conversation["countPosts"]) $lastRead = 0;
else $lastRead = $conversation["countPosts"];
ET::conversationModel()->setLastRead($conversation, ET::$session->userId, $lastRead, true);
// Redirect back to the last place we were at.
if ($this->responseType === RESPONSE_TYPE_DEFAULT) {
$nav = ET::$session->getNavigation("conversation/".$conversation["conversationId"]);
$return = R("return");
redirect($return ? URL($return) : $nav["url"]);
}
$this->render();
}
/**
* Reply to a conversation, or save/discard a draft.
*
* @param int $conversationId The ID of the conversation.
* @return void
*/
public function action_reply($conversationId = false)
{
if (!ET::$session->user or !$this->validateToken()) return;
// Get the conversation.
if (!($conversation = $this->getConversation($conversationId))) return;
// Can the user reply?
if (!$conversation["canReply"]) {
$this->renderMessage(T("Error"), T("message.noPermission"));
return;
}
// Set up a form to handle the input.
$form = ETFactory::make("form");
// Save a draft.
if ($form->validPostBack("saveDraft")) {
$content = $form->getValue("content");
ET::conversationModel()->setDraft($conversation, ET::$session->userId, $content);
// For an AJAX request, add the conversation labels to the output.
if ($this->responseType === RESPONSE_TYPE_AJAX) {
$this->json("labels", $this->getViewContents("conversation/labels", array("labels" => $conversation["labels"])));
$this->render();
return;
}
}
// Add a reply.
else {
// Fetch the members allowed so that notifications can be sent out in the addReply method if this is
// the first post.
$model = ET::conversationModel();
$conversation["membersAllowed"] = $model->getMembersAllowed($conversation);
$postId = $model->addReply($conversation, $form->getValue("content"));
// If there were errors, show them.
if ($model->errorCount())
$this->messages($model->errors(), "warning");
else {
// Update the user's last read.
$model->setLastRead($conversation, ET::$session->userId, $conversation["countPosts"]);
// Return a few bits of information.
$this->json("postId", $postId);
$this->json("starOnReply", (bool)ET::$session->preference("starOnReply", false));
// For an AJAX request, render the new post view.
if ($this->responseType === RESPONSE_TYPE_AJAX) {
$this->data("conversation", $conversation);
$this->data("posts", ET::postModel()->getByConversation($conversation["conversationId"], array("startFrom" => $conversation["countPosts"] - 1, "limit" => 1)));
$this->render("conversation/posts");
return;
}
// Normally, redirect to the post we just made.
elseif ($this->responseType === RESPONSE_TYPE_DEFAULT) {
$this->redirect(URL(R("return", postURL($postId))));
}
}
}
// Redirect back to the conversation's reply box.
if ($this->responseType === RESPONSE_TYPE_DEFAULT) {
$this->redirect(URL(R("return", conversationURL($conversation["conversationId"], $conversation["title"])."#reply")));
}
$this->render();
}
/**
* Format a string of content to be previewed when editing a post.
*
* @return void
*/
public function action_preview()
{
$this->responseType = RESPONSE_TYPE_JSON;
$this->json("content", $this->displayPost(R("content")));
$this->render();
}
/**
* Edit a post.
*
* @param int $postId The post ID.
* @return void
*/
public function action_editPost($postId = false)
{
if (!($post = $this->getPostForEditing($postId))) return;
// Set up a form.
$form = ETFactory::make("form");
$form->action = URL("conversation/editPost/".$post["postId"]);
$form->setValue("content", $post["content"]);
if ($form->isPostBack("cancel"))
$this->redirect(URL(R("return", postURL($postId))));
// Are we saving the post?
if ($form->validPostBack("save")) {
ET::postModel()->editPost($post, $form->getValue("content"));
$this->trigger("editPostAfter", array(&$post));
// Normally, redirect back to the conversation.
if ($this->responseType === RESPONSE_TYPE_DEFAULT) {
redirect(URL(R("return", postURL($postId))));
}
// For an AJAX request, render the post view.
elseif ($this->responseType === RESPONSE_TYPE_AJAX) {
$this->data("post", $this->formatPostForTemplate($post, $post["conversation"]));
$this->render("conversation/post");
return;
}
else {
// JSON?
}
}
$this->data("form", $form);
$this->data("post", $post);
$this->data("controls", $this->getEditControls("p".$post["postId"]));
$this->render("conversation/editPost");
}
/**
* Delete a post.
*
* @param int $postId The post ID.
* @return void
*/
public function action_deletePost($postId = false)
{
if (!($post = $this->getPostForEditing($postId)) or !$this->validateToken()) return;
ET::postModel()->deletePost($post);
// Normally, redirect back to the conversation.
if ($this->responseType === RESPONSE_TYPE_DEFAULT) {
redirect(URL(R("return", postURL($postId))));
}
// For an AJAX request, render the post view.
elseif ($this->responseType === RESPONSE_TYPE_AJAX) {
$this->data("post", $this->formatPostForTemplate($post, $post["conversation"]));
$this->render("conversation/post");
return;
}
}
/**
* Restore a post.
*
* @param int $postId The post ID.
* @return void
*/
public function action_restorePost($postId = false)
{
if (!($post = $this->getPostForEditing($postId)) or !$this->validateToken()) return;
ET::postModel()->restorePost($post);
// Normally, redirect back to the conversation.
if ($this->responseType === RESPONSE_TYPE_DEFAULT) {
redirect(URL(R("return", postURL($postId))));
}
// For an AJAX request, render the post view.
elseif ($this->responseType === RESPONSE_TYPE_AJAX) {
$this->data("post", $this->formatPostForTemplate($post, $post["conversation"]));
$this->render("conversation/post");
return;
}
}
/**
* Format post data into an array which can be used to display the post template view (conversation/post).
*
* @param array $post The post data.
* @param array $conversation The details of the conversation which the post is in.
* @return array A formatted array which can be used in the post template view.
*/
protected function formatPostForTemplate($post, $conversation)
{
$canEdit = ET::postModel()->canEditPost($post, $conversation);
$avatar = avatar($post);
// Construct the post array for use in the post view (conversation/post).
$formatted = array(
"id" => "p".$post["postId"],
"title" => memberLink($post["memberId"], $post["username"]),
"avatar" => (!$post["deleteMemberId"] and $avatar) ? "<a href='".URL(memberURL($post["memberId"], $post["username"]))."'>$avatar</a>" : false,
"class" => $post["deleteMemberId"] ? array("deleted") : array(),
"info" => array(),
"controls" => array(),
"body" => !$post["deleteMemberId"] ? $this->displayPost($post["content"]) : false,
"footer" => array(),
"data" => array(
"id" => $post["postId"],
"memberid" => $post["memberId"]
)
);
$date = smartTime($post["time"], true);
// Add the date/time to the post info as a permalink.
$formatted["info"][] = "<a href='".URL(postURL($post["postId"]))."' class='time' title='".strftime(T("date.full"), $post["time"])."'>".(!empty($conversation["searching"]) ? T("Show in context") : $date)."</a>";
// If the post isn't deleted, add a lot of stuff!
if (!$post["deleteMemberId"]) {
// Add the user's online status / last action next to their name.
if (empty($post["preferences"]["hideOnline"])) {
$lastAction = ET::memberModel()->getLastActionInfo($post["lastActionTime"], $post["lastActionDetail"]);
if ($lastAction[0]) $lastAction[0] = " (".sanitizeHTML($lastAction[0]).")";
if ($lastAction) array_unshift($formatted["info"], "<".(!empty($lastAction[1]) ? "a href='{$lastAction[1]}'" : "span")." class='online' title='".T("Online")."{$lastAction[0]}'><i class='icon-circle'></i></".(!empty($lastAction[1]) ? "a" : "span").">");
}
// Show the user's group type.
$formatted["info"][] = "<span class='group'>".memberGroup($post["account"], $post["groups"])."</span>";
$formatted["class"][] = "group-".$post["account"];
foreach ($post["groups"] as $k => $v) {
if ($k) $formatted["class"][] = "group-".$k;
}
// If the post has been edited, show the time and by whom next to the controls.
if ($post["editMemberId"]) $formatted["controls"][] = "<span class='editedBy'>".sprintf(T("Edited %s by %s"), "<span title='".strftime(T("date.full"), $post["editTime"])."'>".relativeTime($post["editTime"], true)."</span>", name($post["editMemberName"]))."</span>";
// If the user can reply, add a quote control.
if ($conversation["canReply"])
$formatted["controls"][] = "<a href='".URL(conversationURL($conversation["conversationId"], $conversation["title"])."/?quote=".$post["postId"]."#reply")."' title='".T("Quote")."' class='control-quote'><i class='icon-quote-left'></i></a>";
// If the user can edit the post, add edit/delete controls.
if ($canEdit) {
$formatted["controls"][] = "<a href='".URL("conversation/editPost/".$post["postId"])."' title='".T("Edit")."' class='control-edit'><i class='icon-edit'></i></a>";
$formatted["controls"][] = "<a href='".URL("conversation/deletePost/".$post["postId"]."?token=".ET::$session->token)."' title='".T("Delete")."' class='control-delete'><i class='icon-remove'></i></a>";
}
}
// But if the post IS deleted...
else {
// Add the "deleted by" information.
if ($post["deleteMemberId"]) $formatted["controls"][] = "<span>".sprintf(T("Deleted %s by %s"), "<span title='".strftime(T("date.full"), $post["deleteTime"])."'>".relativeTime($post["deleteTime"], true)."</span>", name($post["deleteMemberName"]))."</span>";
// If the user can edit the post, add a restore control.
if ($canEdit)
$formatted["controls"][] = "<a href='".URL("conversation/restorePost/".$post["postId"]."?token=".ET::$session->token)."' title='".T("Restore")."' class='control-restore'><i class='icon-reply'></i></a>";
}
$this->trigger("formatPostForTemplate", array(&$formatted, $post, $conversation));
return $formatted;
}
/**
* Format a post's content to be displayed.
*
* @param string $content The post content to format.
* @return string The formatted post content.
*/
protected function displayPost($content)
{
$words = ET::$session->get("highlight");
return ET::formatter()->init($content)->highlight($words)->format()->get();
}
/**
* Get an array of formatting controls to be shown when editing a post.
*
* @param string $id The ID of the post area (eg. p# or reply.)
* @return array The controls.
*/
protected function getEditControls($id)
{
$controls = array(
"quote" => "<a href='javascript:ETConversation.quote(\"$id\");void(0)' class='control-quote' title='".T("Quote")."' accesskey='q'><i class='icon-quote-left'></i></a>",
);
$this->trigger("getEditControls", array(&$controls, $id));
if (!empty($controls)) {
array_unshift($controls, "<span class='formattingButtons'>");
$controls[] = "</span>";
$controls[] = "<label class='previewCheckbox'><input type='checkbox' id='$id-previewCheckbox' onclick='ETConversation.togglePreview(\"$id\",this.checked)' accesskey='p'/> ".T("Preview")."</label>";
}
return $controls;
}
/**
* Get post data so it can be used to construct a quote of a post.
*
* @param int $postId The ID of the post.
* @param int $conversationId The ID of the conversation that the post is in.
* @return array An array containing the username and the post content.
*/
protected function getPostForQuoting($postId, $conversationId)
{
$result = ET::SQL()
->select("username, content")
->from("post p")
->from("member m", "m.memberId=p.memberId", "inner")
->where("p.postId=:postId")
->where("p.conversationId=:conversationId")
->bind(":postId", $postId)
->bind(":conversationId", $conversationId)
->exec();
if (!$result->numRows()) return false;
$result = $result->firstRow();
// Convert spaces in the member name to non-breaking spaces.
// (Spaces aren't usually allowed in esoTalk usernames, so this is a bit of a "hack" for
// certain esoTalk installations that do allow them.)
$result["username"] = str_replace(" ", "\xc2\xa0", $result["username"]);
return $result;
}
/**
* Shortcut function to get a conversation and render a 404 page if it cannot be found.
*
* @param int $id The ID of the conversation to get, or the post to get the conversation of.
* @param bool $post Whether or not $id is the conversationId or a postId.
* @return bool|array An array of the conversation details, or false if it wasn't found.
*/
public function getConversation($id, $post = false)
{
$conversation = !$post ? ET::conversationModel()->getById($id) : ET::conversationModel()->getByPostId($id);
// Stop here if the conversation doesn't exist, or if the user is not allowed to view it.
if (!$conversation) {
$this->render404(T("message.conversationNotFound"));
return false;
}
return $conversation;
}
/**
* Return post data to work with for an editing action (editPost, deletePost, etc.), but only if the post
* exists and the user has permission to edit it.
*
* @param int $postId The post ID.
* @return bool|array An array of post data, or false if it cannot be edited.
*/
protected function getPostForEditing($postId)
{
// Get the conversation.
if (!($conversation = $this->getConversation($postId, true))) return false;
// Get the post.
$post = ET::postModel()->getById($postId);
// Stop here with an error if the user isn't allowed to edit this post.
if (!ET::postModel()->canEditPost($post, $conversation)) {
// If users only have permission to edit their posts until someone replies, and someone has replied since...
if (C("esoTalk.conversation.editPostTimeLimit") === "reply" and ($conversation["lastPostTime"] > $post["time"] or $conversation["lastPostMemberId"] != $post["memberId"])) $msg = T("message.cannotEditSinceReply");
// Otherwise, show a generic "no permission" message.
else $msg = T("message.noPermission");
$this->renderMessage(T("Error"), $msg);
return false;
}
$post["conversation"] = $conversation;
return $post;
}
}