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