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