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\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
;
19 use wcf\util\ArrayUtil
;
22 * Represents a conversation.
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
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`
54 class Conversation
extends DatabaseObject
implements IPopoverObject
, IRouteController
57 * default participation state
60 const STATE_DEFAULT
= 0;
63 * conversation is hidden but returns visible upon new message
66 const STATE_HIDDEN
= 1;
69 * conversation was left permanently
72 const STATE_LEFT
/*4DEAD*/ = 2;
75 * true if the current user can add users without limitations
78 protected $canAddUnrestricted;
81 * first message object
82 * @var ConversationMessage
84 protected $firstMessage;
87 * true if the current user is an active participant of this conversation
90 protected $isActiveParticipant;
95 public function getTitle()
97 return $this->subject
;
103 public function getLink()
105 return LinkHandler
::getInstance()->getLink('Conversation', [
107 'forceFrontend' => true,
112 * Returns true if this conversation is new for the active user.
116 public function isNew()
118 if (!$this->isDraft
&& $this->lastPostTime
> $this->lastVisitTime
) {
126 * Returns true if the active user doesn't have read the given message.
128 * @param ConversationMessage $message
131 public function isNewMessage(ConversationMessage
$message)
133 if (!$this->isDraft
&& $message->time
> $this->lastVisitTime
) {
141 * Returns true if the conversation is not closed or the user was not removed.
145 public function canReply()
147 return !$this->isClosed
&& !$this->leftAt
&& WCF
::getSession()->getPermission('user.conversation.canReplyToConversation');
151 * Overrides the last message data, used when `leftAt < lastPostTime`.
154 * @param string $username
157 public function setLastMessage($userID, $username, $time)
159 $this->data
['lastPostTime'] = $time;
160 $this->data
['lastPosterID'] = $userID;
161 $this->data
['lastPoster'] = $username;
165 * Loads participation data for given user id (default: current user) on runtime.
166 * You should use Conversation::getUserConversation() instead if possible.
170 public function loadUserParticipation($userID = null)
172 if ($userID === null) {
173 $userID = WCF
::getUser()->userID
;
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);
189 * Returns a specific user conversation.
191 * @param int $conversationID
193 * @return Conversation
195 public static function getUserConversation($conversationID, $userID)
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);
212 * Returns a list of user conversations.
214 * @param int[] $conversationIDs
216 * @return Conversation[]
218 public static function getUserConversations(array $conversationIDs, $userID)
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());
231 while ($row = $statement->fetchArray()) {
232 $conversations[$row['conversationID']] = new self(null, $row);
235 return $conversations;
239 * Returns true if the active user has the permission to read this conversation.
243 public function canRead()
245 if (!WCF
::getUser()->userID
) {
249 if ($this->isDraft
&& $this->userID
== WCF
::getUser()->userID
) {
253 if ($this->participantID
== WCF
::getUser()->userID
&& $this->hideConversation
!= self
::STATE_LEFT
) {
261 * Returns true if the current user can add new participants to this conversation.
265 public function canAddParticipants()
267 if ($this->isDraft
) {
272 if (WCF
::getUser()->userID
!= $this->userID
) {
274 !$this->participantCanInvite
275 && !WCF
::getSession()->getPermission('mod.conversation.canAlwaysInviteUsers')
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')) {
287 if (!$this->isActiveParticipant()) {
295 * Returns true if the current user can add participants without limitations.
299 public function canAddParticipantsUnrestricted()
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
,
313 $joinedAt = $statement->fetchSingleColumn();
315 if ($joinedAt !== false && $joinedAt == 0) {
316 $this->canAddUnrestricted
= true;
321 return $this->canAddUnrestricted
;
325 * Returns the first message in this conversation.
327 * @return ConversationMessage
329 public function getFirstMessage()
331 if ($this->firstMessage
=== null) {
332 $this->firstMessage
= new ConversationMessage($this->firstMessageID
);
335 return $this->firstMessage
;
339 * Sets the first message.
341 * @param ConversationMessage $message
343 public function setFirstMessage(ConversationMessage
$message)
345 $this->firstMessage
= $message;
349 * Returns a list of the ids of all participants.
351 * @param bool $excludeLeftParticipants
354 public function getParticipantIDs($excludeLeftParticipants = false)
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]);
363 $sql = "SELECT participantID
364 FROM wcf" . WCF_N
. "_conversation_to_user
366 $statement = WCF
::getDB()->prepareStatement($sql);
367 $statement->execute($conditions->getParameters());
369 return $statement->fetchAll(\PDO
::FETCH_COLUMN
);
373 * Returns a list of the usernames of all participants.
375 * @param bool $excludeSelf
376 * @param bool $leftByOwnChoice
379 public function getParticipantNames($excludeSelf = false, $leftByOwnChoice = false)
381 $conditions = new PreparedStatementConditionBuilder();
382 $conditions->add("conversationID = ?", [$this->conversationID
]);
384 $conditions->add("conversation_to_user.participantID <> ?", [WCF
::getUser()->userID
]);
386 if ($leftByOwnChoice) {
387 $conditions->add("conversation_to_user.leftByOwnChoice = ?", [1]);
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
395 $statement = WCF
::getDB()->prepareStatement($sql);
396 $statement->execute($conditions->getParameters());
398 return $statement->fetchAll(\PDO
::FETCH_COLUMN
);
402 * Returns false if the active user is the last participant of this conversation.
406 public function hasOtherParticipants()
408 if ($this->userID
== WCF
::getUser()->userID
) {
410 if ($this->participants
== 0) {
416 if ($this->participants
> 1) {
419 if ($this->isInvisible
&& $this->participants
> 0) {
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
) {
444 * Returns true if the current user is an active participant of this conversation.
448 public function isActiveParticipant()
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
,
460 $leftAt = $statement->fetchSingleColumn();
462 $this->isActiveParticipant
= ($leftAt !== false && $leftAt == 0);
465 return $this->isActiveParticipant
;
471 public function getPopoverLinkClass()
473 return 'conversationLink';
477 * Returns true if the given user id (default: current user) is participant
478 * of all given conversation ids.
480 * @param int[] $conversationIDs
484 public static function isParticipant(array $conversationIDs, $userID = null)
486 if ($userID === null) {
487 $userID = WCF
::getUser()->userID
;
490 // check if user is the initial author
491 $conditions = new PreparedStatementConditionBuilder();
492 $conditions->add("conversationID IN (?)", [$conversationIDs]);
493 $conditions->add("userID = ?", [$userID]);
495 $sql = "SELECT conversationID
496 FROM wcf" . WCF_N
. "_conversation
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]);
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
]);
512 $sql = "SELECT conversationID
513 FROM wcf" . WCF_N
. "_conversation_to_user
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]);
523 if (!empty($conversationIDs)) {
531 * Validates the participants.
533 * @param mixed $participants
534 * @param string $field
535 * @param int[] $existingParticipants
536 * @return array $result
537 * @throws UserInputException
539 public static function validateParticipants(
541 $field = 'participants',
542 array $existingParticipants = []
547 // loop through participants and check their settings
548 $participantList = UserProfile
::getUserProfilesByUsername(
549 (\
is_array($participants) ?
$participants : ArrayUtil
::trim(\
explode(',', $participants)))
552 // load user storage at once to avoid multiple queries
554 foreach ($participantList as $user) {
556 $userIDs[] = $user->userID
;
559 UserStorageHandler
::getInstance()->loadStorage($userIDs);
561 foreach ($participantList as $participant => $user) {
563 if ($user === null) {
564 throw new UserInputException($field, 'notFound');
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');
575 self
::validateParticipant($user, $field);
578 $existingParticipants[] = $result[] = $user->userID
;
579 } catch (UserInputException
$e) {
580 $error[] = ['type' => $e->getType(), 'username' => $participant];
584 if (!empty($error)) {
585 throw new UserInputException($field, $error);
592 * Validates the group participants.
594 * @param mixed $participants
595 * @param string $field
596 * @param int[] $existingParticipants
597 * @return array $result
599 public static function validateGroupParticipants(
601 $field = 'participants',
602 array $existingParticipants = []
604 $groupIDs = \
is_array($participants) ?
$participants : ArrayUtil
::toIntegerArray(\
explode(',', $participants));
608 foreach ($groupIDs as $groupID) {
609 $group = UserGroup
::getGroupByID($groupID);
610 /** @noinspection PhpUndefinedFieldInspection */
611 if ($group !== null && $group->canBeAddedAsConversationParticipant
) {
612 $validGroupIDs[] = $groupID;
616 if (!empty($validGroupIDs)) {
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;
629 if (!empty($userIDs)) {
630 $users = UserProfileRuntimeCache
::getInstance()->getObjects($userIDs);
631 UserStorageHandler
::getInstance()->loadStorage($userIDs);
633 foreach ($users as $user) {
635 if ($user->userID
== WCF
::getUser()->userID
) {
637 } elseif (\
in_array($user->userID
, $existingParticipants)) {
643 self
::validateParticipant($user, $field);
646 $result[] = $user->userID
;
647 } catch (UserInputException
$e) {
657 * Validates the given participant.
659 * @param UserProfile $user
660 * @param string $field
661 * @throws UserInputException
663 public static function validateParticipant(UserProfile
$user, $field = 'participants')
665 // check participant's settings and permissions
666 if (!$user->getPermission('user.conversation.canUseConversation')) {
667 throw new UserInputException($field, 'canNotUseConversation');
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');
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');
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');
690 // check participant's mailbox quota
691 if (ConversationHandler
::getInstance()->getConversationCount($user->userID
) >= $user->getPermission('user.conversation.maxConversations')) {
692 throw new UserInputException($field, 'mailboxIsFull');