PageRenderTime 50ms CodeModel.GetById 14ms RepoModel.GetById 0ms app.codeStats 0ms

/models/ETConversationModel.class.php

https://github.com/Ramir1/esoTalk
PHP | 1248 lines | 631 code | 224 blank | 393 comment | 108 complexity | 39d0b3ce4eb6dc7c4cd71d9b9f285f38 MD5 | raw file
Possible License(s): GPL-2.0
  1. <?php
  2. // Copyright 2011 Toby Zerner, Simon Zerner
  3. // This file is part of esoTalk. Please see the included license file for usage information.
  4. if (!defined("IN_ESOTALK")) exit;
  5. /**
  6. * The conversation model provides functions for retrieving and managing conversation data. It also provides
  7. * methods to handle conversation "labels".
  8. *
  9. * @package esoTalk
  10. */
  11. class ETConversationModel extends ETModel {
  12. /**
  13. * An array of conversation "labels". A label is a flag that can apply to a conversation (sticky,
  14. * private, draft, etc.) The array is in the form labelName => SQL expression (eg. IF(c.sticky,1,0))
  15. *
  16. * @var array
  17. */
  18. public static $labels = array();
  19. /**
  20. * Class constructor; sets up the base model functions to use the conversation table.
  21. *
  22. * @return void
  23. */
  24. public function __construct()
  25. {
  26. parent::__construct("conversation");
  27. }
  28. /**
  29. * Adds a label to the collection.
  30. *
  31. * @param string $label The name of the label.
  32. * @param string $expression The SQL expression that will determine whether or not the label is active.
  33. * @return void
  34. */
  35. public static function addLabel($label, $expression)
  36. {
  37. self::$labels[$label] = $expression;
  38. }
  39. /**
  40. * Adds a SELECT field to an SQL query which will get the active state of conversation labels.
  41. *
  42. * We add one field, which we name 'labels', which contains a comma-separated list of
  43. * label expressions defined by addLabel(). This field can then be expanded using expandLabels().
  44. *
  45. * @param ETSQLQuery The SQL query to add the SELECT component to.
  46. * @return void
  47. */
  48. public static function addLabels(&$sql)
  49. {
  50. if (count(self::$labels)) $sql->select("CONCAT_WS(',',".implode(",", self::$labels).")", "labels");
  51. else $sql->select("NULL", "labels");
  52. }
  53. /**
  54. * Expands the value of a label field, added by addLabels(), to an array of active labels.
  55. *
  56. * @param string $labels The value of the 'label' field.
  57. * @return array An array of active labels.
  58. */
  59. public static function expandLabels($labels)
  60. {
  61. $active = array();
  62. if (count(self::$labels)) {
  63. $labels = explode(",", $labels);
  64. $i = 0;
  65. foreach (self::$labels as $k => $v) {
  66. if (!empty($labels[$i])) $active[] = $k;
  67. $i++;
  68. }
  69. }
  70. return $active;
  71. }
  72. /**
  73. * Add a WHERE predicate to an SQL query which will filter out conversations that the user is not
  74. * allowed to see.
  75. *
  76. * @param ETSQLQuery $sql The SQL query to add the WHERE predicate to.
  77. * @param array $member The member to filter out conversations for. If not specified, the currently
  78. * logged-in user will be used.
  79. * @param string $table The conversation table alias used in the SQL query.
  80. * @return void
  81. */
  82. // Get a WHERE clause that makes sure the currently logged in user is allowed to view a conversation.
  83. public function addAllowedPredicate(&$sql, $member = false, $table = "c")
  84. {
  85. // If no member was specified, use the current user.
  86. if (!$member) $member = ET::$session->user;
  87. // If the user is a guest, they can only see conversations that are not drafts and that are not private.
  88. if (!$member) $sql->where("$table.countPosts>0")->where("$table.private=0");
  89. // If the user is logged in...
  90. else {
  91. // Construct a query to get a list of conversationIds that the user is explicitly allowed in.
  92. $allowedQuery = ET::SQL()
  93. ->select("conversationId")
  94. ->from("member_conversation")
  95. ->where("(type='member' AND id=:allowedMemberId) OR (type='group' AND id IN (:allowedGroupIds))")
  96. ->where("allowed=1")
  97. ->get();
  98. // They must be the start member, or the conversation mustn't be a draft or private. If it is private, they must be allowed, using the query above.
  99. $sql->where("($table.startMemberId=:startMemberId OR ($table.countPosts>0 AND ($table.private=0 OR $table.conversationId IN ($allowedQuery))))")
  100. ->bind(":allowedMemberId", $member["memberId"])
  101. ->bind(":allowedGroupIds", ET::groupModel()->getGroupIds($member["account"], array_keys($member["groups"])))
  102. ->bind(":startMemberId", $member["memberId"]);
  103. }
  104. // Additionally, the user must be allowed to view the channel that the conversation is in.
  105. ET::channelModel()->addPermissionPredicate($sql, "view", $member, $table);
  106. }
  107. /**
  108. * Get a single conversation's details.
  109. *
  110. * This function returns an array of fields which is that "standard" for conversation data structure
  111. * within this model.
  112. *
  113. * @param array $wheres An array of WHERE conditions. Regardless of how many conversations match, only
  114. * the first will be returned.
  115. * @return array The conversation details array.
  116. */
  117. public function get($wheres = array())
  118. {
  119. $sql = ET::SQL()
  120. ->select("s.*")
  121. ->select("c.*")
  122. ->select("sm.username", "startMember")
  123. ->select("sm.avatarFormat", "startMemberAvatarFormat")
  124. ->select("ch.title", "channelTitle")
  125. ->select("ch.description", "channelDescription")
  126. ->select("ch.slug", "channelSlug")
  127. ->select("ch.lft", "channelLft")
  128. ->select("ch.rgt", "channelRgt")
  129. // Get the groups that are allowed to view this channel, and the names of those groups.
  130. ->select("GROUP_CONCAT(pv.groupId)", "channelPermissionView")
  131. ->select("GROUP_CONCAT(IF(pvg.name IS NOT NULL, pvg.name, ''))", "channelPermissionViewNames")
  132. // Join the appropriate tables.
  133. ->from("conversation c")
  134. ->from("channel ch", "c.channelId=ch.channelId", "left")
  135. ->from("channel_group pv", "c.channelId=pv.channelId AND pv.view=1", "left")
  136. ->from("group pvg", "pv.groupId=pvg.groupId", "left")
  137. ->from("member_conversation s", "s.conversationId=c.conversationId AND type='member' AND s.id=:userId", "left")->bind(":userId", ET::$session->userId)
  138. ->from("member sm", "sm.memberId=c.startMemberId", "left")
  139. ->where($wheres)
  140. ->groupBy("c.channelId")
  141. ->limit(1);
  142. // Fetch the labels field as well.
  143. $this->addLabels($sql);
  144. // Make sure the user is allowed to view this conversation.
  145. $this->addAllowedPredicate($sql);
  146. // Fetch the user's reply and moderate permissions for this conversation.
  147. if (!ET::$session->isAdmin()) {
  148. $sql->select("BIT_OR(p.reply)", "canReply")
  149. ->select("BIT_OR(p.moderate)", "canModerate")
  150. ->from("channel_group p", "c.channelId=p.channelId AND p.groupId IN (:groupIds)", "left")
  151. ->bind(":groupIds", ET::$session->getGroupIds());
  152. }
  153. // If the user is an administrator, they can always reply and moderate.
  154. else {
  155. $sql->select("1", "canReply")
  156. ->select("1", "canModerate");
  157. }
  158. // Execute the query.
  159. $result = $sql->exec();
  160. if (!$result->numRows()) return false;
  161. // Get all the details from the result into an array.
  162. $conversation = $result->firstRow();
  163. // Expand the labels field into a simple array of active labels.
  164. $conversation["labels"] = $this->expandLabels($conversation["labels"]);
  165. // Convert the separate groups who have permission to view this channel ID/name fields into one.
  166. $conversation["channelPermissionView"] = $this->formatGroupsAllowed($conversation["channelPermissionView"], $conversation["channelPermissionViewNames"]);
  167. // If the conversation is locked and the user can't moderate, then they can't reply.
  168. if ($conversation["locked"] and !$conversation["canModerate"]) $conversation["canReply"] = false;
  169. return $conversation;
  170. }
  171. /**
  172. * Get the conversation that the specified $postId is contained within.
  173. *
  174. * @param int $postId The ID of the post.
  175. * @return array The conversation.
  176. * @see get()
  177. */
  178. public function getByPostId($postId)
  179. {
  180. $subquery = ET::SQL()
  181. ->select("conversationId")
  182. ->from("post")
  183. ->where("postId=:postId")
  184. ->bind(":postId", (int)$postId)
  185. ->get();
  186. return $this->get("c.conversationId=($subquery)");
  187. }
  188. /**
  189. * Get conversation data for the specified conversation ID.
  190. *
  191. * @param int $id The ID of the conversation.
  192. * @return array The conversation.
  193. * @see get()
  194. */
  195. public function getById($id)
  196. {
  197. return $this->get(array("c.conversationId" => (int)$id));
  198. }
  199. /**
  200. * Get an empty conversation details array for a non-existent conversation.
  201. *
  202. * @return array The conversation details array.
  203. */
  204. public function getEmptyConversation()
  205. {
  206. $conversation = array(
  207. "conversationId" => null,
  208. "title" => "",
  209. "startMemberId" => ET::$session->userId,
  210. "startMemberName" => ET::$session->user["username"],
  211. "startMemberAvatarFormat" => ET::$session->user["avatarFormat"],
  212. "countPosts" => 0,
  213. "lastRead" => 0,
  214. "draft" => "",
  215. "private" => false,
  216. "starred" => false,
  217. "muted" => false,
  218. "locked" => false,
  219. "channelId" => ET::$session->get("channelId"),
  220. "channelTitle" => "",
  221. "channelDescription" => "",
  222. "channelSlug" => "",
  223. "channelPermissionView" => array(),
  224. "labels" => array(),
  225. "canModerate" => true,
  226. "canReply" => true
  227. );
  228. // Add the private label if there are entities in the membersAllowed session store.
  229. if (ET::$session->get("membersAllowed")) {
  230. $conversation["private"] = true;
  231. $conversation["labels"][] = "private";
  232. }
  233. // Get the channel info.
  234. $result = ET::SQL()
  235. ->select("c.title")
  236. ->select("c.description")
  237. ->select("c.slug")
  238. ->select("c.lft")
  239. ->select("c.rgt")
  240. ->select("GROUP_CONCAT(pv.groupId)", "channelPermissionView")
  241. ->select("GROUP_CONCAT(IF(pvg.name IS NOT NULL, pvg.name, ''))", "channelPermissionViewNames")
  242. ->from("channel c")
  243. ->from("channel_group pv", "pv.channelId=c.channelId", "left")
  244. ->from("group pvg", "pv.groupId=pvg.groupId", "left")
  245. ->where("c.channelId=:channelId")
  246. ->bind(":channelId", $conversation["channelId"])
  247. ->where("pv.view=1")
  248. ->groupBy("pv.channelId")
  249. ->limit(1)
  250. ->exec();
  251. list($conversation["channelTitle"], $conversation["channelDescription"], $conversation["channelSlug"], $conversation["channelLft"], $conversation["channelRgt"], $conversation["channelPermissionView"], $channelPermissionViewNames) = array_values($result->firstRow());
  252. // Convert the separate groups who have permission to view this channel ID/name fields into one.
  253. $conversation["channelPermissionView"] = $this->formatGroupsAllowed($conversation["channelPermissionView"], $channelPermissionViewNames);
  254. return $conversation;
  255. }
  256. /**
  257. * Combines two separate strings of group IDs and names into one (id => name).
  258. *
  259. * When we fetch conversation details in get() and getNew(), we select a field which contains a
  260. * comma-separated list of group IDs which are allowed to view the conversation's channel, and a field
  261. * with the names of those groups. This function combines those two fields into one nice array.
  262. *
  263. * @param string $permissionView The comma-separated list of group IDs.
  264. * @param string $permissionViewNames The comma-separated list of respective group names.
  265. * @return array A nice array of groupId => names.
  266. */
  267. private function formatGroupsAllowed($permissionView, $permissionViewNames)
  268. {
  269. // Get a list of group IDs that are allowed to view the channel.
  270. $permissionView = array_combine(explode(",", $permissionView), explode(",", $permissionViewNames));
  271. if (isset($permissionView[GROUP_ID_GUEST])) $permissionView[GROUP_ID_GUEST] = ACCOUNT_GUEST;
  272. if (isset($permissionView[GROUP_ID_MEMBER])) $permissionView[GROUP_ID_MEMBER] = ACCOUNT_MEMBER;
  273. // Add in administrators if they're not already in there, because they can always see every channel.
  274. $permissionView[GROUP_ID_ADMINISTRATOR] = ACCOUNT_ADMINISTRATOR;
  275. $permissionView = array_filter($permissionView);
  276. return $permissionView;
  277. }
  278. /**
  279. * Get a list of members who are explicitly allowed to view the given conversation.
  280. * Only members who have been explicitly added to the members allowed list will be returned;
  281. * this function returns an empty array for non-private conversations.
  282. *
  283. * @see getMembersAllowedSummary() for an effective list of members/groups who are allowed to view
  284. * a conversation (which takes channel permissions into consideration.)
  285. * @param array The conversation details.
  286. * @return array An array of entities allowed. Each entry is an array with the following elements:
  287. * type: can be either 'member' or 'group'
  288. * id: the ID of the entity (memberId or groupId)
  289. * name: the name of the entity
  290. * avatarFormat: the member's avatarFormat field (not relevant for groups)
  291. * groups: an array of groups which the member is in (not relevant for groups)
  292. */
  293. public function getMembersAllowed($conversation)
  294. {
  295. $membersAllowed = array();
  296. // If the conversation is not private, then everyone can view it - return an empty array.
  297. if (!$conversation["private"] and $conversation["conversationId"]) return $membersAllowed;
  298. // Construct separate queries for getting a list of the members and groups allowed in a conversation.
  299. // We will marry these later on.
  300. $qMembers = ET::SQL()
  301. ->select("'member'", "type")
  302. ->select("CAST(".($conversation["conversationId"] ? "s.id" : "m.memberId")." AS SIGNED)")
  303. ->select("m.username")
  304. ->select("m.email")
  305. ->select("m.avatarFormat")
  306. ->select("m.account")
  307. ->select("GROUP_CONCAT(g.groupId)")
  308. ->groupBy("m.memberId");
  309. $qGroups = ET::SQL()
  310. ->select("'group'", "type")
  311. ->select("s.id", "id")
  312. ->select("g.name")
  313. ->select("NULL")
  314. ->select("NULL")
  315. ->select("NULL")
  316. ->select("NULL");
  317. // If the conversation doesn't exist, the members allowed are in stored in the session.
  318. // We'll have to get details from the database using the IDs stored in the session.
  319. if (!$conversation["conversationId"]) {
  320. $groups = $members = array();
  321. $sessionMembers = (array)ET::$session->get("membersAllowed");
  322. foreach ($sessionMembers as $member) {
  323. if ($member["type"] == "group") {
  324. // The adminisrtator/member groups aren't really groups, so we can't query the database
  325. // for their information. Instead, add them to the members allowed array manually.
  326. if ($member["id"] == GROUP_ID_ADMINISTRATOR or $member["id"] == GROUP_ID_MEMBER) {
  327. if ($member["id"] == GROUP_ID_ADMINISTRATOR) $name = ACCOUNT_ADMINISTRATOR;
  328. elseif ($member["id"] == GROUP_ID_MEMBER) $name = ACCOUNT_MEMBER;
  329. $membersAllowed[] = array("type" => "group", "id" => $member["id"], "name" => $name, "email" => null, "avatarFormat" => null, "groups" => null);
  330. }
  331. else $groups[] = $member["id"];
  332. }
  333. else $members[] = $member["id"];
  334. }
  335. if (!count($members)) $members[] = null;
  336. if (!count($groups)) $groups[] = null;
  337. // Get member details directly from the members table, and the group details directly from the groups table.
  338. $qMembers->from("member m")->where("m.memberId IN (:memberIds)")->bind(":memberIds", $members);
  339. $qGroups->select("g.groupId", "id")->from("group g")->where("g.groupId IN (:groupIds)")->bind(":groupIds", $groups);
  340. }
  341. // If the conversation does exist, we'll get the members allowed from the database.
  342. else {
  343. $qMembers->from("member_conversation s")
  344. ->from("member m", "s.id=m.memberId", "left")
  345. ->where("s.conversationId=:conversationId")->bind(":conversationId", $conversation["conversationId"])
  346. ->where("s.allowed=1")
  347. ->where("s.type='member'");
  348. $qGroups->from("member_conversation s")
  349. ->from("group g", "s.id=g.groupId", "left")
  350. ->where("s.conversationId=:conversationId")->bind(":conversationId", $conversation["conversationId"])
  351. ->where("s.allowed=1")
  352. ->where("s.type='group'");
  353. }
  354. // Any objections?
  355. $qMembers->from("member_group g", "m.memberId=g.memberId", "left");
  356. // You may now kiss the bride.
  357. $result = ET::SQL("(".$qMembers->get().") UNION (".$qGroups->get().")");
  358. // Go through the results and construct our final "members allowed" array.
  359. while ($entity = $result->nextRow()) {
  360. list($type, $id, $name, $email, $avatarFormat, $account, $groups) = array_values($entity);
  361. $groups = ET::groupModel()->getGroupIds($account, explode(",", $groups));
  362. if ($type == "group") {
  363. if ($id == GROUP_ID_ADMINISTRATOR) $name = ACCOUNT_ADMINISTRATOR;
  364. elseif ($id == GROUP_ID_MEMBER) $name = ACCOUNT_MEMBER;
  365. }
  366. $membersAllowed[] = array("type" => $type, "id" => $id, "name" => $name, "email" => $email, "avatarFormat" => $avatarFormat, "groups" => $groups);
  367. }
  368. // Sort the entities by name.
  369. $membersAllowed = sort2d($membersAllowed, "name", "asc", true, false);
  370. return $membersAllowed;
  371. }
  372. /**
  373. * Get a list of members who are effectively allowed to view the given conversation.
  374. * This function will take into account both the members explicitly allowed to view a conversation
  375. * and who has permission to view the conversation's channel.
  376. *
  377. * @see getMembersAllowed()
  378. * @param array The conversation details.
  379. * @param array An array of members explicitly allowed in the conversation, from getMembersAllowed().
  380. * @return array An array of entities allowed in the same format as the return value of getMembersAllowed().
  381. */
  382. public function getMembersAllowedSummary($conversation, $membersAllowed = array())
  383. {
  384. $groups = array();
  385. $members = array();
  386. $channelGroupIds = array_keys($conversation["channelPermissionView"]);
  387. // If the conversation ISN'T private...
  388. if (!$conversation["private"]) {
  389. // If guests aren't allowed to view this channel (i.e. not everyone), then we need to
  390. // explicitly show who can view the channel.
  391. if (!in_array(GROUP_ID_GUEST, $channelGroupIds)) {
  392. // If members can view the channel, that covers everyone.
  393. if (in_array(GROUP_ID_MEMBER, $channelGroupIds)) $groups[GROUP_ID_MEMBER] = ACCOUNT_MEMBER;
  394. // Otherwise, go through each of the groups who can view the channel and add them to the groups array for later.
  395. else {
  396. foreach ($channelGroupIds as $id) $groups[$id] = $conversation["channelPermissionView"][$id];
  397. }
  398. }
  399. }
  400. // If the conversation IS private...
  401. else {
  402. // Sort the members.
  403. $count = count($membersAllowed);
  404. // Loop through the members allowed and filter out all the groups and members into separate arrays.
  405. foreach ($membersAllowed as $k => $member) {
  406. if ($member["type"] == "group") {
  407. // Only add the group to the final list if it is allowed to view the channel.
  408. if (!ET::groupModel()->groupIdsAllowedInGroupIds($member["id"], $channelGroupIds)) continue;
  409. $groups[$member["id"]] = $member["name"];
  410. }
  411. else {
  412. // Only add the member to the final list if they are allowed to view the channel.
  413. if (!ET::groupModel()->groupIdsAllowedInGroupIds($member["groups"], $channelGroupIds)) continue;
  414. $members[] = $member;
  415. }
  416. }
  417. }
  418. // Now, create a final list of members/groups who can view this conversation.
  419. $membersAllowedSummary = array();
  420. // If members are allowed to view this conversation, just show that (as members covers all members.)
  421. if (isset($groups[GROUP_ID_MEMBER])) {
  422. $membersAllowedSummary[] = array("type" => "group", "id" => GROUP_ID_MEMBER, "name" => ACCOUNT_MEMBER, "email" => null);
  423. }
  424. else {
  425. // Loop through the groups allowed and add them to the summary.
  426. foreach ($groups as $id => $name) {
  427. $membersAllowedSummary[] = array("type" => "group", "id" => $id, "name" => $name, "email" => null);
  428. }
  429. // Loop through the members allowed and add them to the summary.
  430. $groupIds = array_keys($groups);
  431. foreach ($members as $member) {
  432. // If the member is already covered by one of the groups being displayed, don't show them.
  433. if (ET::groupModel()->groupIdsAllowedInGroupIds($member["groups"], $groupIds) or !$member["name"]) continue;
  434. $membersAllowedSummary[] = $member;
  435. }
  436. }
  437. // Whew! All done. Hopefully that wasn't too confusing.
  438. return $membersAllowedSummary;
  439. }
  440. /**
  441. * Get a breadcrumb of channels leading to and including the channel that a conversation is in.
  442. *
  443. * @param array The conversation details.
  444. * @return array An array containing the tree of channels and sub-channels that the conversation is in.
  445. */
  446. public function getChannelPath($conversation)
  447. {
  448. $channels = ET::channelModel()->getAll();
  449. $path = array();
  450. foreach ($channels as $channel) {
  451. if ($channel["lft"] <= $conversation["channelLft"] and $channel["rgt"] >= $conversation["channelRgt"])
  452. $path[] = $channel;
  453. }
  454. return $path;
  455. }
  456. /**
  457. * Start a new converastion. Assumes the creator is the currently logged in user.
  458. *
  459. * @param array $data An array of the conversation's details: title, channelId, content.
  460. * @param array $membersAllowed An array of entities allowed to view the conversation, in the same format
  461. * as the return value of getMembersAllowed()
  462. * @param bool $isDraft Whether or not the conversation is a draft.
  463. * @return bool|array An array containing the new conversation ID and the new post ID, or false if
  464. * there was an error.
  465. */
  466. public function create($data, $membersAllowed = array(), $isDraft = false)
  467. {
  468. // We can't do this if we're not logged in.
  469. if (!ET::$session->user) return false;
  470. // If the title is blank but the user is only saving a draft, call it "Untitled conversation."
  471. if ($isDraft and !$data["title"]) $data["title"] = T("Untitled conversation");
  472. // Check for errors; validate the title and the post content.
  473. $this->validate("title", $data["title"], array($this, "validateTitle"));
  474. $this->validate("content", $data["content"], array(ET::postModel(), "validateContent"));
  475. $content = $data["content"];
  476. unset($data["content"]);
  477. // Flood control!
  478. if (ET::$session->isFlooding()) $this->error("flooding", "waitToReply");
  479. // Make sure that we have permission to post in this channel.
  480. $data["channelId"] = (int)$data["channelId"];
  481. if (!ET::channelModel()->hasPermission($data["channelId"], "start"))
  482. $this->error("channelId", "invalidChannel");
  483. // Did we encounter any errors? Don't continue.
  484. if ($this->errorCount()) return false;
  485. // Start a notification group. This means that for all notifications sent out until endNotifcationGroup
  486. // is called, each individual user will receive a maximum of one.
  487. ET::activityModel()->startNotificationGroup();
  488. // Add some more data fields to insert into the database.
  489. $time = time();
  490. $data["startMemberId"] = ET::$session->userId;
  491. $data["startTime"] = $time;
  492. $data["lastPostMemberId"] = ET::$session->userId;
  493. $data["lastPostTime"] = $time;
  494. $data["private"] = !empty($membersAllowed);
  495. $data["countPosts"] = $isDraft ? 0 : 1;
  496. // Insert the conversation into the database.
  497. $conversationId = parent::create($data);
  498. // Update the member's conversation count.
  499. ET::SQL()
  500. ->update("member")
  501. ->set("countConversations", "countConversations + 1", false)
  502. ->where("memberId", ET::$session->userId)
  503. ->exec();
  504. // Update the channel's converastion count.
  505. ET::SQL()
  506. ->update("channel")
  507. ->set("countConversations", "countConversations + 1", false)
  508. ->where("channelId", $data["channelId"])
  509. ->exec();
  510. // Get our newly created conversation.
  511. $conversation = $this->getById($conversationId);
  512. // Add the first post or save the draft.
  513. $postId = null;
  514. if ($isDraft) {
  515. $this->setDraft($conversation, ET::$session->userId, $content);
  516. }
  517. else {
  518. $postId = ET::postModel()->create($conversationId, ET::$session->userId, $content);
  519. // If the conversation is private, send out notifications to the allowed members.
  520. if (!empty($membersAllowed)) {
  521. $memberIds = array();
  522. foreach ($membersAllowed as $member) {
  523. if ($member["type"] == "member") $memberIds[] = $member["id"];
  524. }
  525. ET::conversationModel()->privateAddNotification($conversation, $memberIds, true);
  526. }
  527. }
  528. // If the conversation is private, add the allowed members to the database.
  529. if (!empty($membersAllowed)) {
  530. $inserts = array();
  531. foreach ($membersAllowed as $member) $inserts[] = array($conversationId, $member["type"], $member["id"], 1);
  532. ET::SQL()
  533. ->insert("member_conversation")
  534. ->setMultiple(array("conversationId", "type", "id", "allowed"), $inserts)
  535. ->setOnDuplicateKey("allowed", 1)
  536. ->exec();
  537. }
  538. // If the user has the "star on reply" preference checked, star the conversation.
  539. if (ET::$session->preference("starOnReply"))
  540. $this->setStatus($conversation, ET::$session->userId, array("starred" => true));
  541. $this->trigger("createAfter", array($conversation, $postId, $content));
  542. ET::activityModel()->endNotificationGroup();
  543. return array($conversationId, $postId);
  544. }
  545. /**
  546. * Add a reply to an existing conversation. Assumes the creator is the currently logged in user.
  547. *
  548. * @param array $conversation The conversation to add the reply to. The conversation's details will
  549. * be updated (post count, last post time, etc.)
  550. * @param string $content The post content.
  551. * @return int|bool The new post's ID, or false if there was an error.
  552. */
  553. public function addReply(&$conversation, $content)
  554. {
  555. // We can't do this if we're not logged in.
  556. if (!ET::$session->user) return false;
  557. // Flood control!
  558. if (ET::$session->isFlooding()) {
  559. $this->error("flooding", sprintf(T("message.waitToReply"), C("esoTalk.conversation.timeBetweenPosts")));
  560. return false;
  561. }
  562. // Start a notification group. This means that for all notifications sent out until endNotifcationGroup
  563. // is called, each individual user will receive a maximum of one.
  564. ET::activityModel()->startNotificationGroup();
  565. // Create the post. If there were validation errors, get them from the post model and add them to this model.
  566. $postModel = ET::postModel();
  567. $postId = $postModel->create($conversation["conversationId"], ET::$session->userId, $content, $conversation["title"]);
  568. if (!$postId) $this->error($postModel->errors());
  569. // Did we encounter any errors? Don't continue.
  570. if ($this->errorCount()) return false;
  571. // Update the conversations table with the new post count, last post/action times, and last post member.
  572. $time = time();
  573. $update = array(
  574. "countPosts" => $conversation["countPosts"] + 1,
  575. "lastPostMemberId" => ET::$session->userId,
  576. "lastPostTime" => $time,
  577. );
  578. // Also update the conversation's start time if this is the first post.
  579. if ($conversation["countPosts"] == 0) $update["startTime"] = $time;
  580. $this->updateById($conversation["conversationId"], $update);
  581. // If the user had a draft saved in this conversation before adding this reply, erase it now.
  582. // Also, if the user has the "star on reply" option checked, star the conversation.
  583. $update = array();
  584. if ($conversation["draft"]) $update["draft"] = null;
  585. if (ET::$session->preference("starOnReply")) $update["starred"] = true;
  586. if (count($update)) {
  587. $this->setStatus($conversation, ET::$session->userId, $update);
  588. }
  589. // Send out notifications to people who have starred this conversation.
  590. // We get all members who have starred the conversation and have no unread posts in it.
  591. $sql = ET::SQL()
  592. ->from("member_conversation s", "s.conversationId=:conversationId AND s.type='member' AND s.id=m.memberId AND s.starred=1 AND s.lastRead>=:posts AND s.id!=:userId", "inner")
  593. ->bind(":conversationId", $conversation["conversationId"])
  594. ->bind(":posts", $conversation["countPosts"])
  595. ->bind(":userId", ET::$session->userId);
  596. $members = ET::memberModel()->getWithSQL($sql);
  597. $data = array(
  598. "conversationId" => $conversation["conversationId"],
  599. "postId" => $postId,
  600. "title" => $conversation["title"]
  601. );
  602. $emailData = array("content" => $content);
  603. foreach ($members as $member) {
  604. ET::activityModel()->create("post", $member, ET::$session->user, $data, $emailData);
  605. }
  606. // Update the conversation post count.
  607. $conversation["countPosts"]++;
  608. // If this is the first reply (ie. the conversation was a draft and now it isn't), send notifications to
  609. // members who are in the membersAllowed list.
  610. if ($conversation["countPosts"] == 1 and !empty($conversation["membersAllowed"])) {
  611. $memberIds = array();
  612. foreach ($conversation["membersAllowed"] as $member) {
  613. if ($member["type"] == "member") $memberIds[] = $member["id"];
  614. }
  615. $this->privateAddNotification($conversation, $memberIds, true);
  616. }
  617. $this->trigger("addReplyAfter", array($conversation, $postId, $content));
  618. ET::activityModel()->endNotificationGroup();
  619. return $postId;
  620. }
  621. /**
  622. * Delete a conversation, and all its posts and other associations.
  623. *
  624. * @param array $wheres An array of WHERE predicates.
  625. * @return bool true on success, false on error.
  626. */
  627. public function delete($wheres = array())
  628. {
  629. // Get conversation IDs that match these WHERE conditions.
  630. $ids = array();
  631. $result = ET::SQL()->select("conversationId")->from("conversation c")->where($wheres)->exec();
  632. while ($row = $result->nextRow()) $ids[] = $row["conversationId"];
  633. // Decrease channel and member conversation counts for these conversations.
  634. // There might be a more efficient way to do this than one query per conversation... but good enough for now!
  635. foreach ($ids as $id) {
  636. ET::SQL()
  637. ->update("member")
  638. ->set("countConversations", "GREATEST(0, CAST(countConversations AS SIGNED) - 1)", false)
  639. ->where("memberId = (".ET::SQL()->select("startMemberId")->from("conversation")->where("conversationId", $id)->get().")")
  640. ->exec();
  641. ET::SQL()
  642. ->update("channel")
  643. ->set("countConversations", "GREATEST(0, CAST(countConversations AS SIGNED) - 1)", false)
  644. ->where("channelId = (".ET::SQL()->select("channelId")->from("conversation")->where("conversationId", $id)->get().")")
  645. ->exec();
  646. }
  647. // Really, we should decrease post counts as well, but I'll leave that for now.
  648. // Delete the conversation, posts, member_conversation, and activity rows.
  649. ET::SQL()
  650. ->delete("c, m, p")
  651. ->from("conversation c")
  652. ->from("member_conversation m", "m.conversationId=c.conversationId", "left")
  653. ->from("post p", "p.conversationId=c.conversationId", "left")
  654. ->from("activity a", "a.conversationId=c.conversationId", "left")
  655. ->where("c.conversationId IN (:conversationIds)")
  656. ->bind(":conversationIds", $ids)
  657. ->exec();
  658. return true;
  659. }
  660. /**
  661. * Delete an existing record in the model's table with a particular ID.
  662. *
  663. * @param mixed $id The ID of the record to delete.
  664. * @return ETSQLResult
  665. */
  666. public function deleteById($id)
  667. {
  668. return $this->delete(array("c.conversationId" => $id));
  669. }
  670. /**
  671. * Set a member's status entry for a conversation (their record in the member_conversation table.)
  672. * This should not be used directly for setting a draft or 'muted'. setDraft and setMuted should be
  673. * used for that.
  674. *
  675. * @param array $conversation The conversation to set the member's status for.
  676. * @param int $memberId The member to set the status for.
  677. * @param array $data An array of key => value data to save to the database.
  678. * @param string $type The entity type (group or member).
  679. * @return void
  680. */
  681. public function setStatus(&$conversation, $memberId, $data, $type = "member")
  682. {
  683. $keys = array(
  684. "type" => $type,
  685. "id" => $memberId,
  686. "conversationId" => $conversation["conversationId"]
  687. );
  688. ET::SQL()->insert("member_conversation")->set($keys + $data)->setOnDuplicateKey($data)->exec();
  689. }
  690. /**
  691. * Set a member's draft for a conversation.
  692. *
  693. * @param array $conversation The conversation to set the draft on. The conversation array's labels
  694. * and draft attribute will be updated.
  695. * @param int $memberId The member to set the status for.
  696. * @param string $draft The draft content.
  697. * @return bool Returns true on success, or false if there is an error.
  698. */
  699. public function setDraft(&$conversation, $memberId, $draft = null)
  700. {
  701. // Validate the post content if applicable.
  702. if ($draft !== null) $this->validate("content", $draft, array(ET::postModel(), "validateContent"));
  703. if ($this->errorCount()) return false;
  704. // Save the draft.
  705. $this->setStatus($conversation, $memberId, array("draft" => $draft));
  706. // Add or remove the draft label.
  707. $this->addOrRemoveLabel($conversation, "draft", $draft !== null);
  708. $conversation["draft"] = $draft;
  709. return true;
  710. }
  711. /**
  712. * Set a member's last read position for a conversation.
  713. *
  714. * @param array $conversation The conversation to set the last read position on. The conversation array's
  715. * lastRead attribute will be updated.
  716. * @param int $memberId The member to set the status for.
  717. * @param int $lastRead The position of the post that was last read.
  718. * @return bool Returns true on success, or false if there is an error.
  719. */
  720. public function setLastRead(&$conversation, $memberId, $lastRead)
  721. {
  722. $lastRead = min($lastRead, $conversation["countPosts"]);
  723. if ($lastRead <= $conversation["lastRead"]) return true;
  724. // Set the last read status.
  725. $this->setStatus($conversation, $memberId, array("lastRead" => $lastRead));
  726. $conversation["lastRead"] = $lastRead;
  727. return true;
  728. }
  729. /**
  730. * Set a member's muted flag for a conversation.
  731. *
  732. * @param array $conversation The conversation to set the draft on. The conversation array's labels
  733. * and muted attribute will be updated.
  734. * @param int $memberId The member to set the flag for.
  735. * @param bool $muted Whether or not to set the conversation to muted.
  736. * @return void
  737. */
  738. public function setMuted(&$conversation, $memberId, $muted)
  739. {
  740. $muted = (bool)$muted;
  741. $this->setStatus($conversation, $memberId, array("muted" => $muted));
  742. $this->addOrRemoveLabel($conversation, "muted", $muted);
  743. $conversation["muted"] = $muted;
  744. }
  745. /**
  746. * Set the sticky flag of a conversation.
  747. *
  748. * @param array $conversation The conversation to set the draft on. The conversation array's labels
  749. * and sticky attribute will be updated.
  750. * @param bool $sticky Whether or not the conversation is stickied.
  751. * @return void
  752. */
  753. public function setSticky(&$conversation, $sticky)
  754. {
  755. $sticky = (bool)$sticky;
  756. $this->updateById($conversation["conversationId"], array(
  757. "sticky" => $sticky
  758. ));
  759. $this->addOrRemoveLabel($conversation, "sticky", $sticky);
  760. $conversation["sticky"] = $sticky;
  761. }
  762. /**
  763. * Set the locked flag of a conversation.
  764. *
  765. * @param array $conversation The conversation to set the draft on. The conversation array's labels
  766. * and locked attribute will be updated.
  767. * @param bool $locked Whether or not the conversation is locked.
  768. * @return void
  769. */
  770. public function setLocked(&$conversation, $locked)
  771. {
  772. $locked = (bool)$locked;
  773. $this->updateById($conversation["conversationId"], array(
  774. "locked" => $locked
  775. ));
  776. $this->addOrRemoveLabel($conversation, "locked", $locked);
  777. $conversation["locked"] = $locked;
  778. }
  779. /**
  780. * Convenience method to add or remove a certain label from a conversation's labels array.
  781. *
  782. * @param array $conversation The conversation to add/remove the label from.
  783. * @param string $label The name of the label.
  784. * @param bool $add true to add the label, false to remove it.
  785. * @return void
  786. */
  787. protected function addOrRemoveLabel(&$conversation, $label, $add = true)
  788. {
  789. if ($add and !in_array($label, $conversation["labels"]))
  790. $conversation["labels"][] = $label;
  791. elseif (!$add and ($k = array_search($label, $conversation["labels"])) !== false)
  792. unset($conversation["labels"][$k]);
  793. }
  794. /**
  795. * Set the title of a conversation.
  796. *
  797. * @param array $conversation The conversation to set the title of. The conversation array's title
  798. * attribute will be updated.
  799. * @param string $title The new title of the conversation.
  800. * @return bool Returns true on success, or false if there is an error.
  801. */
  802. public function setTitle(&$conversation, $title)
  803. {
  804. $this->validate("title", $title, array($this, "validateTitle"));
  805. if ($this->errorCount()) return false;
  806. $this->updateById($conversation["conversationId"], array(
  807. "title" => $title
  808. ));
  809. // Update the title column in the posts table as well (which is used for fulltext searching).
  810. ET::postModel()->update(array("title" => $title), array("conversationId" => $conversation["conversationId"]));
  811. $conversation["title"] = $title;
  812. return true;
  813. }
  814. /**
  815. * Validate the title of a conversation.
  816. *
  817. * @param string $title The conversation title.
  818. * @return bool|string Returns an error string or false if there are no errors.
  819. */
  820. public function validateTitle($title)
  821. {
  822. if (!strlen($title)) return "emptyTitle";
  823. }
  824. /**
  825. * Set the channel of a conversation.
  826. *
  827. * @param array $conversation The conversation to set the channel for. The conversation array's channelId
  828. * attribute will be updated.
  829. * @param int $channelId Whether or not the conversation is locked.
  830. * @return bool Returns true on success, or false if there is an error.
  831. */
  832. public function setChannel(&$conversation, $channelId)
  833. {
  834. if (!ET::channelModel()->hasPermission($channelId, "start")) $this->error("channelId", T("message.noPermission"));
  835. if ($this->errorCount()) return false;
  836. // Decrease the conversation/post count of the old channel.
  837. ET::SQL()->update("channel")
  838. ->set("countConversations", "countConversations - 1", false)
  839. ->set("countPosts", "countPosts - :posts", false)
  840. ->bind(":posts", $conversation["countPosts"])
  841. ->where("channelId=:channelId")
  842. ->bind(":channelId", $conversation["channelId"])
  843. ->exec();
  844. $this->updateById($conversation["conversationId"], array(
  845. "channelId" => $channelId
  846. ));
  847. // Increase the conversation/post count of the new channel.
  848. ET::SQL()->update("channel")
  849. ->set("countConversations", "countConversations + 1", false)
  850. ->set("countPosts", "countPosts + :posts", false)
  851. ->bind(":posts", $conversation["countPosts"])
  852. ->where("channelId=:channelId")
  853. ->bind(":channelId", $channelId)
  854. ->exec();
  855. $conversation["channelId"] = $channelId;
  856. return true;
  857. }
  858. /**
  859. * Given a name (intended to be the input of the "add members allowed" form), this function finds a matching
  860. * group or member and returns an array of its details to be used in addMember().
  861. *
  862. * @param string $name The input.
  863. * @return bool|array Returns an array of the entity's details (in the same format as getMembersAllowed()),
  864. * or false if no entity was found.
  865. */
  866. public function getMemberFromName($name)
  867. {
  868. $memberId = $memberName = false;
  869. // Get a list of all member groups, and add administrators + members to it.
  870. $groups = ET::groupModel()->getAll();
  871. $groups[GROUP_ID_ADMINISTRATOR] = array("name" => ACCOUNT_ADMINISTRATOR);
  872. $groups[GROUP_ID_MEMBER] = array("name" => ACCOUNT_MEMBER);
  873. // Go through each of the groups and see if one of them matches the name. If so, return its details.
  874. $lowerName = strtolower($name);
  875. foreach ($groups as $id => $group) {
  876. $group = $group["name"];
  877. if ($lowerName == strtolower(T("group.$group.plural", $group)) or $lowerName == strtolower($group)) {
  878. return array("type" => "group", "id" => $id, "name" => $group);
  879. }
  880. }
  881. // Otherwise, search for a member in the database with a matching name.
  882. $name = str_replace("%", "", $name);
  883. $result = ET::SQL()
  884. ->select("m.memberId")
  885. ->select("m.username")
  886. ->select("m.avatarFormat")
  887. ->select("m.account")
  888. ->select("GROUP_CONCAT(g.groupId)", "groups")
  889. ->from("member m")
  890. ->from("member_group g", "m.memberId=g.memberId", "left")
  891. ->where("m.username=:name OR m.username LIKE :nameLike")
  892. ->bind(":name", $name)
  893. ->bind(":nameLike", $name."%")
  894. ->groupBy("m.memberId")
  895. ->orderBy("m.username=:nameOrder DESC")
  896. ->bind(":nameOrder", $name)
  897. ->limit(1)
  898. ->exec();
  899. if (!$result->numRows()) return false;
  900. // Get the result and return it as an array.
  901. $row = $result->firstRow();
  902. $row["groups"] = ET::groupModel()->getGroupIds($row["account"], explode(",", $row["groups"]));
  903. return array("type" => "member", "id" => $row["memberId"], "name" => $row["username"], "avatarFormat" => $row["avatarFormat"], "groups" => $row["groups"]);
  904. }
  905. /**
  906. * Add a member to a conversation, i.e. give them permission to view it and make the conversation private.
  907. *
  908. * @param array $conversation The conversation to add the member to. The conversation array's membersAllowed
  909. * and private attributes will be updated.
  910. * @param array $member The entity to add. This can be from getMemberFromName().
  911. * @return void
  912. */
  913. public function addMember(&$conversation, $member)
  914. {
  915. // If the conversation exists, add this member to the database as allowed.
  916. if ($conversation["conversationId"]) {
  917. // Email the member(s) - we have to do this before we put them in the db because it will only email them if they
  918. // don't already have a record for this conversation in the status table.
  919. if ($conversation["countPosts"] > 0 and $member["type"] == "member") $this->privateAddNotification($conversation, $member["id"]);
  920. // Set the conversation's private field to true and update the last action time.
  921. if (!$conversation["private"]) {
  922. $this->updateById($conversation["conversationId"], array("private" => true));
  923. $conversation["private"] = true;
  924. }
  925. // Allow the member to view the conversation in the status table.
  926. $this->setStatus($conversation, $member["id"], array("allowed" => true), $member["type"]);
  927. // Make sure the the owner of the conversation is allowed to view it.
  928. $this->setStatus($conversation, $conversation["startMemberId"], array("allowed" => true));
  929. }
  930. // If the conversation doesn't exist, add this member to the session members allowed store.
  931. else {
  932. $membersAllowed = ET::$session->get("membersAllowed", array());
  933. $member = array("type" => $member["type"], "id" => $member["id"]);
  934. if (!in_array($member, $membersAllowed)) $membersAllowed[] = $member;
  935. // Make sure the the owner of the conversation is allowed to view it.
  936. $member = array("type" => "member", "id" => $conversation["startMemberId"]);
  937. if (!in_array($member, $membersAllowed)) $membersAllowed[] = $member;
  938. ET::$session->store("membersAllowed", $membersAllowed);
  939. }
  940. // Add the private label to the conversation.
  941. $this->addOrRemoveLabel($conversation, "private", true);
  942. $conversation["private"] = true;
  943. }
  944. /**
  945. * Remove a member from a conversation, i.e. revoke their permission to view it and make the conversation
  946. * not private if there are no members left.
  947. *
  948. * @param array $conversation The conversation to remove the member from. The conversation array's membersAllowed
  949. * and private attributes will be updated.
  950. * @param array $member The entity to remove. This should have two elements: type and id.
  951. * @return void
  952. */
  953. public function removeMember(&$conversation, $member)
  954. {
  955. // If the conversation exists, remove the member from the database.
  956. if ($conversation["conversationId"]) {
  957. // Disallow the member to view the conversation in the status table.
  958. // Also unstar the conversation so they will no longer receive email notifications.
  959. $this->setStatus($conversation, $member["id"], array("allowed" => false, "starred" => false), $member["type"]);
  960. }
  961. // Otherwise remove it from the session.
  962. else {
  963. $membersAllowed = ET::$session->get("membersAllowed", array());
  964. foreach ($membersAllowed as $k => $m) {
  965. if ($m["type"] == $member["type"] and $m["id"] == $member["id"]) unset($membersAllowed[$k]);
  966. }
  967. ET::$session->store("membersAllowed", $membersAllowed);
  968. }
  969. // Update the conversation's membersAllowed array.
  970. foreach ($conversation["membersAllowed"] as $k => $m) {
  971. if ($m["type"] == $member["type"] and $m["id"] == $member["id"]) unset($conversation["membersAllowed"][$k]);
  972. }
  973. // If there are no members left allowed in the conversation, then unmark the conversation as private.
  974. if (empty($conversation["membersAllowed"])) {
  975. $conversation["membersAllowed"] = array();
  976. $conversation["private"] = false;
  977. $this->addOrRemoveLabel($conversation, "private", false);
  978. // Turn off conversation's private field in the database.
  979. if ($conversation["conversationId"])
  980. $this->updateById($conversation["conversationId"], array("private" => false));
  981. }
  982. }
  983. /**
  984. * Send private conversation invitation notifications to a list of members. A notification will only
  985. * be sent if this is the first time a member has been added to the conversation, to prevent intentional
  986. * email spamming.
  987. *
  988. * @param array $conversation The conversation to that we're sending out notifications for.
  989. * @param array $memberIds A list of member IDs to send the notifications to.
  990. * @param bool $notifyAll If set to true, all members will be notified regardless of if they have been
  991. * added to this conversation before.
  992. * @return void
  993. */
  994. protected function privateAddNotification($conversation, $memberIds, $notifyAll = false)
  995. {
  996. $memberIds = (array)$memberIds;
  997. // Remove the currently logged in user from the list of member IDs.
  998. if (($k = array_search(ET::$session->userId, $memberIds)) !== false) unset($memberIds[$k]);
  999. if (!count($memberIds)) return;
  1000. // Get the member details for this list of member IDs.
  1001. $sql = ET::SQL()
  1002. ->from("member_conversation s", "s.conversationId=:conversationId AND s.type='member' AND s.id=m.memberId", "left")
  1003. ->bind(":conversationId", $conversation["conversationId"])
  1004. ->where("m.memberId IN (:memberIds)")
  1005. ->bind(":memberIds", $memberIds);
  1006. // Only get members where the member_conversation row doesn't exist (implying that this is the first time
  1007. // they've been added to the conversation.)
  1008. if (!$notifyAll) $sql->where("s.id IS NULL");
  1009. $members = ET::memberModel()->getWithSQL($sql);
  1010. $data = array(
  1011. "conversationId" => $conversation["conversationId"],
  1012. "title" => $conversation["title"]
  1013. );
  1014. foreach ($members as $member) {
  1015. ET::activityModel()->create("privateAdd", $member, ET::$session->user, $data);
  1016. }
  1017. }
  1018. }
  1019. // Add default labels.
  1020. ETConversationModel::addLabel("sticky", "IF(c.sticky=1,1,0)");
  1021. ETConversationModel::addLabel("private", "IF(c.private=1,1,0)");
  1022. ETConversationModel::addLabel("locked", "IF(c.locked=1,1,0)");
  1023. ETConversationModel::addLabel("draft", "IF(s.draft IS NOT NULL,1,0)");
  1024. ETConversationModel::addLabel("muted", "IF(s.muted=1,1,0)");