4c306062aee646d1ab4d878b17534aab554ad46f
[GitHub/WoltLab/com.woltlab.wcf.conversation.git] / files / lib / data / conversation / Conversation.class.php
1 <?php
2
3 namespace wcf\data\conversation;
4
5 use wcf\data\conversation\message\ConversationMessage;
6 use wcf\data\DatabaseObject;
7 use wcf\data\IPopoverObject;
8 use wcf\data\user\group\UserGroup;
9 use wcf\data\user\UserProfile;
10 use wcf\system\cache\runtime\UserProfileRuntimeCache;
11 use wcf\system\conversation\ConversationHandler;
12 use wcf\system\database\util\PreparedStatementConditionBuilder;
13 use wcf\system\exception\UserInputException;
14 use wcf\system\request\IRouteController;
15 use wcf\system\request\LinkHandler;
16 use wcf\system\user\storage\UserStorageHandler;
17 use wcf\system\WCF;
18 use wcf\util\ArrayUtil;
19
20 /**
21 * Represents a conversation.
22 *
23 * @author Marcel Werk
24 * @copyright 2001-2019 WoltLab GmbH
25 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
26 * @package WoltLabSuite\Core\Data\Conversation
27 *
28 * @property-read int $conversationID unique id of the conversation
29 * @property-read string $subject subject of the conversation
30 * @property-read int $time timestamp at which the conversation has been started
31 * @property-read int $firstMessageID id of the first conversation message
32 * @property-read int|null $userID id of the user who started the conversation or `null` if the user does not exist anymore
33 * @property-read string $username name of the user who started the conversation
34 * @property-read int $lastPostTime timestamp at which the conversation's last message has been written
35 * @property-read int|null $lastPosterID id of the user who wrote the conversation's last message or `null` if the user does not exist anymore
36 * @property-read string $lastPoster name of the user who wrote the conversation's last message
37 * @property-read int $replies number of replies on the conversation
38 * @property-read int $attachments total number of attachments in all messages of the conversation
39 * @property-read int $participants number of participants of the conversations
40 * @property-read string $participantSummary serialized data of five of the conversation participants (sorted by username)
41 * @property-read int $participantCanInvite is `1` if participants can invite other users to join the conversation, otherwise `0`
42 * @property-read int $isClosed is `1` if the conversation is closed for new messages, otherwise `0`
43 * @property-read int $isDraft is `1` if the conversation is a draft only, thus not sent to any participant, otherwise `0`
44 * @property-read string $draftData serialized ids of the participants and invisible participants if conversation is a draft, otherwise `0`
45 * @property-read int|null $participantID id of the user whose conversations are fetched via `UserConversationList`, otherwise `null`
46 * @property-read int|null $hideConversation is `1` if the user has hidden conversation, otherwise `0`; is `null` if the conversation has not been fetched via `UserConversationList`
47 * @property-read int|null $isInvisible is `1` if the user is invisible in conversation, otherwise `0`; is `null` if the conversation has not been fetched via `UserConversationList`
48 * @property-read int|null $lastVisitTime timestamp at which the user last visited the conversation after a new messsage had been written or `0` if they have not visited it at all; is `null` if the conversation has not been fetched via `UserConversationList`
49 * @property-read int|null $joinedAt timestamp at which the user joined the conversation; is `null` if the conversation has not been fetched via `UserConversationList`
50 * @property-read int|null $leftAt timestamp at which the user left the conversation or `0` if they did not leave the conversation; is `null` if the conversation has not been fetched via `UserConversationList`
51 * @property-read int|null $lastMessageID id of the last message written before the user left the conversation or `0` if they did not leave the conversation; is `null` if the conversation has not been fetched via `UserConversationList`
52 */
53 class Conversation extends DatabaseObject implements IPopoverObject, IRouteController
54 {
55 /**
56 * default participation state
57 * @var int
58 */
59 const STATE_DEFAULT = 0;
60
61 /**
62 * conversation is hidden but returns visible upon new message
63 * @var int
64 */
65 const STATE_HIDDEN = 1;
66
67 /**
68 * conversation was left permanently
69 * @var int
70 */
71 const STATE_LEFT/*4DEAD*/ = 2;
72
73 /**
74 * true if the current user can add users without limitations
75 * @var bool
76 */
77 protected $canAddUnrestricted;
78
79 /**
80 * first message object
81 * @var ConversationMessage
82 */
83 protected $firstMessage;
84
85 /**
86 * true if the current user is an active participant of this conversation
87 * @var bool
88 */
89 protected $isActiveParticipant;
90
91 /**
92 * @inheritDoc
93 */
94 public function getTitle()
95 {
96 return $this->subject;
97 }
98
99 /**
100 * @inheritDoc
101 */
102 public function getLink()
103 {
104 return LinkHandler::getInstance()->getLink('Conversation', [
105 'object' => $this,
106 'forceFrontend' => true,
107 ]);
108 }
109
110 /**
111 * Returns true if this conversation is new for the active user.
112 *
113 * @return bool
114 */
115 public function isNew()
116 {
117 if (!$this->isDraft && $this->lastPostTime > $this->lastVisitTime) {
118 return true;
119 }
120
121 return false;
122 }
123
124 /**
125 * Returns true if the active user doesn't have read the given message.
126 *
127 * @param ConversationMessage $message
128 * @return bool
129 */
130 public function isNewMessage(ConversationMessage $message)
131 {
132 if (!$this->isDraft && $message->time > $this->lastVisitTime) {
133 return true;
134 }
135
136 return false;
137 }
138
139 /**
140 * Returns true if the conversation is not closed or the user was not removed.
141 *
142 * @return bool
143 */
144 public function canReply()
145 {
146 return !$this->isClosed && !$this->leftAt && WCF::getSession()->getPermission('user.conversation.canReplyToConversation');
147 }
148
149 /**
150 * Overrides the last message data, used when `leftAt < lastPostTime`.
151 *
152 * @param int $userID
153 * @param string $username
154 * @param int $time
155 */
156 public function setLastMessage($userID, $username, $time)
157 {
158 $this->data['lastPostTime'] = $time;
159 $this->data['lastPosterID'] = $userID;
160 $this->data['lastPoster'] = $username;
161 }
162
163 /**
164 * Loads participation data for given user id (default: current user) on runtime.
165 * You should use Conversation::getUserConversation() instead if possible.
166 *
167 * @param int $userID
168 */
169 public function loadUserParticipation($userID = null)
170 {
171 if ($userID === null) {
172 $userID = WCF::getUser()->userID;
173 }
174
175 $sql = "SELECT *
176 FROM wcf" . WCF_N . "_conversation_to_user
177 WHERE participantID = ?
178 AND conversationID = ?";
179 $statement = WCF::getDB()->prepareStatement($sql);
180 $statement->execute([$userID, $this->conversationID]);
181 $row = $statement->fetchArray();
182 if ($row !== false) {
183 $this->data = \array_merge($this->data, $row);
184 }
185 }
186
187 /**
188 * Returns a specific user conversation.
189 *
190 * @param int $conversationID
191 * @param int $userID
192 * @return Conversation
193 */
194 public static function getUserConversation($conversationID, $userID)
195 {
196 $sql = "SELECT conversation_to_user.*, conversation.*
197 FROM wcf" . WCF_N . "_conversation conversation
198 LEFT JOIN wcf" . WCF_N . "_conversation_to_user conversation_to_user
199 ON conversation_to_user.participantID = ?
200 AND conversation_to_user.conversationID = conversation.conversationID
201 WHERE conversation.conversationID = ?";
202 $statement = WCF::getDB()->prepareStatement($sql);
203 $statement->execute([$userID, $conversationID]);
204 $row = $statement->fetchArray();
205 if ($row !== false) {
206 return new self(null, $row);
207 }
208 }
209
210 /**
211 * Returns a list of user conversations.
212 *
213 * @param int[] $conversationIDs
214 * @param int $userID
215 * @return Conversation[]
216 */
217 public static function getUserConversations(array $conversationIDs, $userID)
218 {
219 $conditionBuilder = new PreparedStatementConditionBuilder();
220 $conditionBuilder->add('conversation.conversationID IN (?)', [$conversationIDs]);
221 $sql = "SELECT conversation_to_user.*, conversation.*
222 FROM wcf" . WCF_N . "_conversation conversation
223 LEFT JOIN wcf" . WCF_N . "_conversation_to_user conversation_to_user
224 ON conversation_to_user.participantID = " . $userID . "
225 AND conversation_to_user.conversationID = conversation.conversationID
226 " . $conditionBuilder;
227 $statement = WCF::getDB()->prepareStatement($sql);
228 $statement->execute($conditionBuilder->getParameters());
229 $conversations = [];
230 while ($row = $statement->fetchArray()) {
231 $conversations[$row['conversationID']] = new self(null, $row);
232 }
233
234 return $conversations;
235 }
236
237 /**
238 * Returns true if the active user has the permission to read this conversation.
239 *
240 * @return bool
241 */
242 public function canRead()
243 {
244 if (!WCF::getUser()->userID) {
245 return false;
246 }
247
248 if ($this->isDraft && $this->userID == WCF::getUser()->userID) {
249 return true;
250 }
251
252 if ($this->participantID == WCF::getUser()->userID && $this->hideConversation != self::STATE_LEFT) {
253 return true;
254 }
255
256 return false;
257 }
258
259 /**
260 * Returns true if the current user can add new participants to this conversation.
261 *
262 * @return bool
263 */
264 public function canAddParticipants()
265 {
266 if ($this->isDraft) {
267 return false;
268 }
269
270 // check permissions
271 if (WCF::getUser()->userID != $this->userID) {
272 if (
273 !$this->participantCanInvite
274 && !WCF::getSession()->getPermission('mod.conversation.canAlwaysInviteUsers')
275 ) {
276 return false;
277 }
278 }
279
280 // check for maximum number of participants
281 // note: 'participants' does not track invisible participants, this will be checked on the fly!
282 if ($this->participants >= WCF::getSession()->getPermission('user.conversation.maxParticipants')) {
283 return false;
284 }
285
286 if (!$this->isActiveParticipant()) {
287 return false;
288 }
289
290 return true;
291 }
292
293 /**
294 * Returns true if the current user can add participants without limitations.
295 *
296 * @return bool
297 */
298 public function canAddParticipantsUnrestricted()
299 {
300 if ($this->canAddUnrestricted === null) {
301 $this->canAddUnrestricted = false;
302 if ($this->isActiveParticipant()) {
303 $sql = "SELECT joinedAt
304 FROM wcf" . WCF_N . "_conversation_to_user
305 WHERE conversationID = ?
306 AND participantID = ?";
307 $statement = WCF::getDB()->prepareStatement($sql);
308 $statement->execute([
309 $this->conversationID,
310 WCF::getUser()->userID,
311 ]);
312 $joinedAt = $statement->fetchSingleColumn();
313
314 if ($joinedAt !== false && $joinedAt == 0) {
315 $this->canAddUnrestricted = true;
316 }
317 }
318 }
319
320 return $this->canAddUnrestricted;
321 }
322
323 /**
324 * Returns the first message in this conversation.
325 *
326 * @return ConversationMessage
327 */
328 public function getFirstMessage()
329 {
330 if ($this->firstMessage === null) {
331 $this->firstMessage = new ConversationMessage($this->firstMessageID);
332 }
333
334 return $this->firstMessage;
335 }
336
337 /**
338 * Sets the first message.
339 *
340 * @param ConversationMessage $message
341 */
342 public function setFirstMessage(ConversationMessage $message)
343 {
344 $this->firstMessage = $message;
345 }
346
347 /**
348 * Returns a list of the ids of all participants.
349 *
350 * @param bool $excludeLeftParticipants
351 * @return int[]
352 */
353 public function getParticipantIDs($excludeLeftParticipants = false)
354 {
355 $conditions = new PreparedStatementConditionBuilder();
356 $conditions->add("conversationID = ?", [$this->conversationID]);
357 $conditions->add("participantID IS NOT NULL");
358 if ($excludeLeftParticipants) {
359 $conditions->add("(hideConversation <> ? AND leftAt = ?)", [self::STATE_LEFT, 0]);
360 }
361
362 $sql = "SELECT participantID
363 FROM wcf" . WCF_N . "_conversation_to_user
364 " . $conditions;
365 $statement = WCF::getDB()->prepareStatement($sql);
366 $statement->execute($conditions->getParameters());
367
368 return $statement->fetchAll(\PDO::FETCH_COLUMN);
369 }
370
371 /**
372 * Returns a list of the usernames of all participants.
373 *
374 * @param bool $excludeSelf
375 * @param bool $leftByOwnChoice
376 * @return string[]
377 */
378 public function getParticipantNames($excludeSelf = false, $leftByOwnChoice = false)
379 {
380 $conditions = new PreparedStatementConditionBuilder();
381 $conditions->add("conversationID = ?", [$this->conversationID]);
382 if ($excludeSelf) {
383 $conditions->add("conversation_to_user.participantID <> ?", [WCF::getUser()->userID]);
384 }
385 if ($leftByOwnChoice) {
386 $conditions->add("conversation_to_user.leftByOwnChoice = ?", [1]);
387 }
388
389 $sql = "SELECT user_table.username
390 FROM wcf" . WCF_N . "_conversation_to_user conversation_to_user
391 LEFT JOIN wcf" . WCF_N . "_user user_table
392 ON user_table.userID = conversation_to_user.participantID
393 " . $conditions;
394 $statement = WCF::getDB()->prepareStatement($sql);
395 $statement->execute($conditions->getParameters());
396
397 return $statement->fetchAll(\PDO::FETCH_COLUMN);
398 }
399
400 /**
401 * Returns false if the active user is the last participant of this conversation.
402 *
403 * @return bool
404 */
405 public function hasOtherParticipants()
406 {
407 if ($this->userID == WCF::getUser()->userID) {
408 // author
409 if ($this->participants == 0) {
410 return false;
411 }
412
413 return true;
414 } else {
415 if ($this->participants > 1) {
416 return true;
417 }
418 if ($this->isInvisible && $this->participants > 0) {
419 return true;
420 }
421
422 if ($this->userID) {
423 // check if author has left the conversation
424 $sql = "SELECT hideConversation
425 FROM wcf" . WCF_N . "_conversation_to_user
426 WHERE conversationID = ?
427 AND participantID = ?";
428 $statement = WCF::getDB()->prepareStatement($sql);
429 $statement->execute([$this->conversationID, $this->userID]);
430 $row = $statement->fetchArray();
431 if ($row !== false) {
432 if ($row['hideConversation'] != self::STATE_LEFT) {
433 return true;
434 }
435 }
436 }
437
438 return false;
439 }
440 }
441
442 /**
443 * Returns true if the current user is an active participant of this conversation.
444 *
445 * @return bool
446 */
447 public function isActiveParticipant()
448 {
449 if ($this->isActiveParticipant === null) {
450 $sql = "SELECT leftAt
451 FROM wcf" . WCF_N . "_conversation_to_user
452 WHERE conversationID = ?
453 AND participantID = ?";
454 $statement = WCF::getDB()->prepareStatement($sql);
455 $statement->execute([
456 $this->conversationID,
457 WCF::getUser()->userID,
458 ]);
459 $leftAt = $statement->fetchSingleColumn();
460
461 $this->isActiveParticipant = ($leftAt !== false && $leftAt == 0);
462 }
463
464 return $this->isActiveParticipant;
465 }
466
467 /**
468 * @inheritDoc
469 */
470 public function getPopoverLinkClass()
471 {
472 return 'conversationLink';
473 }
474
475 /**
476 * Returns true if the given user id (default: current user) is participant
477 * of all given conversation ids.
478 *
479 * @param int[] $conversationIDs
480 * @param int $userID
481 * @return bool
482 */
483 public static function isParticipant(array $conversationIDs, $userID = null)
484 {
485 if ($userID === null) {
486 $userID = WCF::getUser()->userID;
487 }
488
489 // check if user is the initial author
490 $conditions = new PreparedStatementConditionBuilder();
491 $conditions->add("conversationID IN (?)", [$conversationIDs]);
492 $conditions->add("userID = ?", [$userID]);
493
494 $sql = "SELECT conversationID
495 FROM wcf" . WCF_N . "_conversation
496 " . $conditions;
497 $statement = WCF::getDB()->prepareStatement($sql);
498 $statement->execute($conditions->getParameters());
499 while ($row = $statement->fetchArray()) {
500 $index = \array_search($row['conversationID'], $conversationIDs);
501 unset($conversationIDs[$index]);
502 }
503
504 // check for participation
505 if (!empty($conversationIDs)) {
506 $conditions = new PreparedStatementConditionBuilder();
507 $conditions->add("conversationID IN (?)", [$conversationIDs]);
508 $conditions->add("participantID = ?", [$userID]);
509 $conditions->add("hideConversation <> ?", [self::STATE_LEFT]);
510
511 $sql = "SELECT conversationID
512 FROM wcf" . WCF_N . "_conversation_to_user
513 " . $conditions;
514 $statement = WCF::getDB()->prepareStatement($sql);
515 $statement->execute($conditions->getParameters());
516 while ($row = $statement->fetchArray()) {
517 $index = \array_search($row['conversationID'], $conversationIDs);
518 unset($conversationIDs[$index]);
519 }
520 }
521
522 if (!empty($conversationIDs)) {
523 return false;
524 }
525
526 return true;
527 }
528
529 /**
530 * Validates the participants.
531 *
532 * @param mixed $participants
533 * @param string $field
534 * @param int[] $existingParticipants
535 * @return array $result
536 * @throws UserInputException
537 */
538 public static function validateParticipants(
539 $participants,
540 $field = 'participants',
541 array $existingParticipants = []
542 ) {
543 $result = [];
544 $error = [];
545
546 // loop through participants and check their settings
547 $participantList = UserProfile::getUserProfilesByUsername(
548 (\is_array($participants) ? $participants : ArrayUtil::trim(\explode(',', $participants)))
549 );
550
551 // load user storage at once to avoid multiple queries
552 $userIDs = [];
553 foreach ($participantList as $user) {
554 if ($user) {
555 $userIDs[] = $user->userID;
556 }
557 }
558 UserStorageHandler::getInstance()->loadStorage($userIDs);
559
560 foreach ($participantList as $participant => $user) {
561 try {
562 if ($user === null) {
563 throw new UserInputException($field, 'notFound');
564 }
565
566 // user is author
567 if ($user->userID == WCF::getUser()->userID) {
568 throw new UserInputException($field, 'isAuthor');
569 } elseif (\in_array($user->userID, $existingParticipants)) {
570 throw new UserInputException($field, 'duplicate');
571 }
572
573 // validate user
574 self::validateParticipant($user, $field);
575
576 // no error
577 $existingParticipants[] = $result[] = $user->userID;
578 } catch (UserInputException $e) {
579 $error[] = ['type' => $e->getType(), 'username' => $participant];
580 }
581 }
582
583 if (!empty($error)) {
584 throw new UserInputException($field, $error);
585 }
586
587 return $result;
588 }
589
590 /**
591 * Validates the group participants.
592 *
593 * @param mixed $participants
594 * @param string $field
595 * @param int[] $existingParticipants
596 * @return array $result
597 */
598 public static function validateGroupParticipants(
599 $participants,
600 $field = 'participants',
601 array $existingParticipants = []
602 ) {
603 $groupIDs = \is_array($participants) ? $participants : ArrayUtil::toIntegerArray(\explode(',', $participants));
604 $validGroupIDs = [];
605 $result = [];
606
607 foreach ($groupIDs as $groupID) {
608 $group = UserGroup::getGroupByID($groupID);
609 /** @noinspection PhpUndefinedFieldInspection */
610 if ($group !== null && $group->canBeAddedAsConversationParticipant) {
611 $validGroupIDs[] = $groupID;
612 }
613 }
614
615 if (!empty($validGroupIDs)) {
616 $userIDs = [];
617 $conditionBuilder = new PreparedStatementConditionBuilder();
618 $conditionBuilder->add('groupID IN (?)', [$validGroupIDs]);
619 $sql = "SELECT DISTINCT userID
620 FROM wcf" . WCF_N . "_user_to_group
621 " . $conditionBuilder;
622 $statement = WCF::getDB()->prepareStatement($sql);
623 $statement->execute($conditionBuilder->getParameters());
624 while ($userID = $statement->fetchColumn()) {
625 $userIDs[] = $userID;
626 }
627
628 if (!empty($userIDs)) {
629 $users = UserProfileRuntimeCache::getInstance()->getObjects($userIDs);
630 UserStorageHandler::getInstance()->loadStorage($userIDs);
631
632 foreach ($users as $user) {
633 // user is author
634 if ($user->userID == WCF::getUser()->userID) {
635 continue;
636 } elseif (\in_array($user->userID, $existingParticipants)) {
637 continue;
638 }
639
640 try {
641 // validate user
642 self::validateParticipant($user, $field);
643
644 // no error
645 $result[] = $user->userID;
646 } catch (UserInputException $e) {
647 }
648 }
649 }
650 }
651
652 return $result;
653 }
654
655 /**
656 * Validates the given participant.
657 *
658 * @param UserProfile $user
659 * @param string $field
660 * @throws UserInputException
661 */
662 public static function validateParticipant(UserProfile $user, $field = 'participants')
663 {
664 // check participant's settings and permissions
665 if (!$user->getPermission('user.conversation.canUseConversation')) {
666 throw new UserInputException($field, 'canNotUseConversation');
667 }
668
669 if (!WCF::getSession()->getPermission('user.profile.cannotBeIgnored')) {
670 // check if user wants to receive any conversations
671 /** @noinspection PhpUndefinedFieldInspection */
672 if ($user->canSendConversation == 2) {
673 throw new UserInputException($field, 'doesNotAcceptConversation');
674 }
675
676 // check if user only wants to receive conversations by
677 // users they are following and if the active user is followed
678 // by the relevant user
679 /** @noinspection PhpUndefinedFieldInspection */
680 if ($user->canSendConversation == 1 && !$user->isFollowing(WCF::getUser()->userID)) {
681 throw new UserInputException($field, 'doesNotAcceptConversation');
682 }
683
684 // active user is ignored by participant
685 if ($user->isIgnoredUser(WCF::getUser()->userID)) {
686 throw new UserInputException($field, 'ignoresYou');
687 }
688
689 // check participant's mailbox quota
690 if (ConversationHandler::getInstance()->getConversationCount($user->userID) >= $user->getPermission('user.conversation.maxConversations')) {
691 throw new UserInputException($field, 'mailboxIsFull');
692 }
693 }
694 }
695 }