3 namespace wcf\data\conversation
;
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
;
18 use wcf\util\ArrayUtil
;
21 * Represents a conversation.
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
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`
53 class Conversation
extends DatabaseObject
implements IPopoverObject
, IRouteController
56 * default participation state
59 const STATE_DEFAULT
= 0;
62 * conversation is hidden but returns visible upon new message
65 const STATE_HIDDEN
= 1;
68 * conversation was left permanently
71 const STATE_LEFT
/*4DEAD*/ = 2;
74 * true if the current user can add users without limitations
77 protected $canAddUnrestricted;
80 * first message object
81 * @var ConversationMessage
83 protected $firstMessage;
86 * true if the current user is an active participant of this conversation
89 protected $isActiveParticipant;
94 public function getTitle()
96 return $this->subject
;
102 public function getLink()
104 return LinkHandler
::getInstance()->getLink('Conversation', [
106 'forceFrontend' => true,
111 * Returns true if this conversation is new for the active user.
115 public function isNew()
117 if (!$this->isDraft
&& $this->lastPostTime
> $this->lastVisitTime
) {
125 * Returns true if the active user doesn't have read the given message.
127 * @param ConversationMessage $message
130 public function isNewMessage(ConversationMessage
$message)
132 if (!$this->isDraft
&& $message->time
> $this->lastVisitTime
) {
140 * Returns true if the conversation is not closed or the user was not removed.
144 public function canReply()
146 return !$this->isClosed
&& !$this->leftAt
&& WCF
::getSession()->getPermission('user.conversation.canReplyToConversation');
150 * Overrides the last message data, used when `leftAt < lastPostTime`.
153 * @param string $username
156 public function setLastMessage($userID, $username, $time)
158 $this->data
['lastPostTime'] = $time;
159 $this->data
['lastPosterID'] = $userID;
160 $this->data
['lastPoster'] = $username;
164 * Loads participation data for given user id (default: current user) on runtime.
165 * You should use Conversation::getUserConversation() instead if possible.
169 public function loadUserParticipation($userID = null)
171 if ($userID === null) {
172 $userID = WCF
::getUser()->userID
;
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);
188 * Returns a specific user conversation.
190 * @param int $conversationID
192 * @return Conversation
194 public static function getUserConversation($conversationID, $userID)
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);
211 * Returns a list of user conversations.
213 * @param int[] $conversationIDs
215 * @return Conversation[]
217 public static function getUserConversations(array $conversationIDs, $userID)
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());
230 while ($row = $statement->fetchArray()) {
231 $conversations[$row['conversationID']] = new self(null, $row);
234 return $conversations;
238 * Returns true if the active user has the permission to read this conversation.
242 public function canRead()
244 if (!WCF
::getUser()->userID
) {
248 if ($this->isDraft
&& $this->userID
== WCF
::getUser()->userID
) {
252 if ($this->participantID
== WCF
::getUser()->userID
&& $this->hideConversation
!= self
::STATE_LEFT
) {
260 * Returns true if the current user can add new participants to this conversation.
264 public function canAddParticipants()
266 if ($this->isDraft
) {
271 if (WCF
::getUser()->userID
!= $this->userID
) {
273 !$this->participantCanInvite
274 && !WCF
::getSession()->getPermission('mod.conversation.canAlwaysInviteUsers')
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')) {
286 if (!$this->isActiveParticipant()) {
294 * Returns true if the current user can add participants without limitations.
298 public function canAddParticipantsUnrestricted()
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
,
312 $joinedAt = $statement->fetchSingleColumn();
314 if ($joinedAt !== false && $joinedAt == 0) {
315 $this->canAddUnrestricted
= true;
320 return $this->canAddUnrestricted
;
324 * Returns the first message in this conversation.
326 * @return ConversationMessage
328 public function getFirstMessage()
330 if ($this->firstMessage
=== null) {
331 $this->firstMessage
= new ConversationMessage($this->firstMessageID
);
334 return $this->firstMessage
;
338 * Sets the first message.
340 * @param ConversationMessage $message
342 public function setFirstMessage(ConversationMessage
$message)
344 $this->firstMessage
= $message;
348 * Returns a list of the ids of all participants.
350 * @param bool $excludeLeftParticipants
353 public function getParticipantIDs($excludeLeftParticipants = false)
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]);
362 $sql = "SELECT participantID
363 FROM wcf" . WCF_N
. "_conversation_to_user
365 $statement = WCF
::getDB()->prepareStatement($sql);
366 $statement->execute($conditions->getParameters());
368 return $statement->fetchAll(\PDO
::FETCH_COLUMN
);
372 * Returns a list of the usernames of all participants.
374 * @param bool $excludeSelf
375 * @param bool $leftByOwnChoice
378 public function getParticipantNames($excludeSelf = false, $leftByOwnChoice = false)
380 $conditions = new PreparedStatementConditionBuilder();
381 $conditions->add("conversationID = ?", [$this->conversationID
]);
383 $conditions->add("conversation_to_user.participantID <> ?", [WCF
::getUser()->userID
]);
385 if ($leftByOwnChoice) {
386 $conditions->add("conversation_to_user.leftByOwnChoice = ?", [1]);
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
394 $statement = WCF
::getDB()->prepareStatement($sql);
395 $statement->execute($conditions->getParameters());
397 return $statement->fetchAll(\PDO
::FETCH_COLUMN
);
401 * Returns false if the active user is the last participant of this conversation.
405 public function hasOtherParticipants()
407 if ($this->userID
== WCF
::getUser()->userID
) {
409 if ($this->participants
== 0) {
415 if ($this->participants
> 1) {
418 if ($this->isInvisible
&& $this->participants
> 0) {
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
) {
443 * Returns true if the current user is an active participant of this conversation.
447 public function isActiveParticipant()
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
,
459 $leftAt = $statement->fetchSingleColumn();
461 $this->isActiveParticipant
= ($leftAt !== false && $leftAt == 0);
464 return $this->isActiveParticipant
;
470 public function getPopoverLinkClass()
472 return 'conversationLink';
476 * Returns true if the given user id (default: current user) is participant
477 * of all given conversation ids.
479 * @param int[] $conversationIDs
483 public static function isParticipant(array $conversationIDs, $userID = null)
485 if ($userID === null) {
486 $userID = WCF
::getUser()->userID
;
489 // check if user is the initial author
490 $conditions = new PreparedStatementConditionBuilder();
491 $conditions->add("conversationID IN (?)", [$conversationIDs]);
492 $conditions->add("userID = ?", [$userID]);
494 $sql = "SELECT conversationID
495 FROM wcf" . WCF_N
. "_conversation
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]);
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
]);
511 $sql = "SELECT conversationID
512 FROM wcf" . WCF_N
. "_conversation_to_user
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]);
522 if (!empty($conversationIDs)) {
530 * Validates the participants.
532 * @param mixed $participants
533 * @param string $field
534 * @param int[] $existingParticipants
535 * @return array $result
536 * @throws UserInputException
538 public static function validateParticipants(
540 $field = 'participants',
541 array $existingParticipants = []
546 // loop through participants and check their settings
547 $participantList = UserProfile
::getUserProfilesByUsername(
548 (\
is_array($participants) ?
$participants : ArrayUtil
::trim(\
explode(',', $participants)))
551 // load user storage at once to avoid multiple queries
553 foreach ($participantList as $user) {
555 $userIDs[] = $user->userID
;
558 UserStorageHandler
::getInstance()->loadStorage($userIDs);
560 foreach ($participantList as $participant => $user) {
562 if ($user === null) {
563 throw new UserInputException($field, 'notFound');
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');
574 self
::validateParticipant($user, $field);
577 $existingParticipants[] = $result[] = $user->userID
;
578 } catch (UserInputException
$e) {
579 $error[] = ['type' => $e->getType(), 'username' => $participant];
583 if (!empty($error)) {
584 throw new UserInputException($field, $error);
591 * Validates the group participants.
593 * @param mixed $participants
594 * @param string $field
595 * @param int[] $existingParticipants
596 * @return array $result
598 public static function validateGroupParticipants(
600 $field = 'participants',
601 array $existingParticipants = []
603 $groupIDs = \
is_array($participants) ?
$participants : ArrayUtil
::toIntegerArray(\
explode(',', $participants));
607 foreach ($groupIDs as $groupID) {
608 $group = UserGroup
::getGroupByID($groupID);
609 /** @noinspection PhpUndefinedFieldInspection */
610 if ($group !== null && $group->canBeAddedAsConversationParticipant
) {
611 $validGroupIDs[] = $groupID;
615 if (!empty($validGroupIDs)) {
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;
628 if (!empty($userIDs)) {
629 $users = UserProfileRuntimeCache
::getInstance()->getObjects($userIDs);
630 UserStorageHandler
::getInstance()->loadStorage($userIDs);
632 foreach ($users as $user) {
634 if ($user->userID
== WCF
::getUser()->userID
) {
636 } elseif (\
in_array($user->userID
, $existingParticipants)) {
642 self
::validateParticipant($user, $field);
645 $result[] = $user->userID
;
646 } catch (UserInputException
$e) {
656 * Validates the given participant.
658 * @param UserProfile $user
659 * @param string $field
660 * @throws UserInputException
662 public static function validateParticipant(UserProfile
$user, $field = 'participants')
664 // check participant's settings and permissions
665 if (!$user->getPermission('user.conversation.canUseConversation')) {
666 throw new UserInputException($field, 'canNotUseConversation');
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');
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');
684 // active user is ignored by participant
685 if ($user->isIgnoredUser(WCF
::getUser()->userID
)) {
686 throw new UserInputException($field, 'ignoresYou');
689 // check participant's mailbox quota
690 if (ConversationHandler
::getInstance()->getConversationCount($user->userID
) >= $user->getPermission('user.conversation.maxConversations')) {
691 throw new UserInputException($field, 'mailboxIsFull');