diff --git a/files/acp/database/install_com.woltlab.wcf.conversation.php b/files/acp/database/install_com.woltlab.wcf.conversation.php index 25e8e3de..b589f15d 100644 --- a/files/acp/database/install_com.woltlab.wcf.conversation.php +++ b/files/acp/database/install_com.woltlab.wcf.conversation.php @@ -1,9 +1,9 @@ + * @author Tim Duesterhus + * @copyright 2001-2025 WoltLab GmbH + * @license GNU Lesser General Public License */ use wcf\system\database\table\column\DefaultFalseBooleanDatabaseTableColumn; @@ -15,7 +15,6 @@ use wcf\system\database\table\column\NotNullVarchar255DatabaseTableColumn; use wcf\system\database\table\column\ObjectIdDatabaseTableColumn; use wcf\system\database\table\column\SmallintDatabaseTableColumn; -use wcf\system\database\table\column\TextDatabaseTableColumn; use wcf\system\database\table\column\TinyintDatabaseTableColumn; use wcf\system\database\table\column\VarcharDatabaseTableColumn; use wcf\system\database\table\DatabaseTable; @@ -57,7 +56,6 @@ MediumintDatabaseTableColumn::create('participants') ->notNull() ->defaultValue(0), - TextDatabaseTableColumn::create('participantSummary'), DefaultFalseBooleanDatabaseTableColumn::create('participantCanInvite'), DefaultFalseBooleanDatabaseTableColumn::create('isClosed'), DefaultFalseBooleanDatabaseTableColumn::create('isDraft'), @@ -83,6 +81,7 @@ ]), DatabaseTable::create('wcf1_conversation_to_user') ->columns([ + ObjectIdDatabaseTableColumn::create('conversationParticipantID'), NotNullInt10DatabaseTableColumn::create('conversationID'), IntDatabaseTableColumn::create('participantID') ->length(10), @@ -104,6 +103,8 @@ DefaultTrueBooleanDatabaseTableColumn::create('leftByOwnChoice'), ]) ->indices([ + DatabaseTablePrimaryIndex::create() + ->columns(['conversationID']), DatabaseTableIndex::create('participantID') ->columns(['participantID', 'conversationID']) ->type(DatabaseTableIndex::UNIQUE_TYPE), diff --git a/files/acp/database/update_com.woltlab.wcf.conversation_6.2.php b/files/acp/database/update_com.woltlab.wcf.conversation_6.2.php new file mode 100644 index 00000000..0b7a07c4 --- /dev/null +++ b/files/acp/database/update_com.woltlab.wcf.conversation_6.2.php @@ -0,0 +1,27 @@ + + */ + +use wcf\system\database\table\column\ObjectIdDatabaseTableColumn; +use wcf\system\database\table\column\TextDatabaseTableColumn; +use wcf\system\database\table\index\DatabaseTablePrimaryIndex; +use wcf\system\database\table\PartialDatabaseTable; + +return [ + PartialDatabaseTable::create('wcf1_conversation') + ->columns([ + TextDatabaseTableColumn::create('participantSummary')->drop(), + ]), + PartialDatabaseTable::create('wcf1_conversation_to_user') + ->columns([ + ObjectIdDatabaseTableColumn::create('conversationParticipantID'), + ]) + ->indices([ + DatabaseTablePrimaryIndex::create() + ->columns(['conversationParticipantID']), + ]), +]; diff --git a/files/js/WoltLabSuite/Core/Conversation/Ui/MarkAllAsRead.js b/files/js/WoltLabSuite/Core/Conversation/Component/MarkAllAsRead.js similarity index 60% rename from files/js/WoltLabSuite/Core/Conversation/Ui/MarkAllAsRead.js rename to files/js/WoltLabSuite/Core/Conversation/Component/MarkAllAsRead.js index a5345edc..a2a20339 100644 --- a/files/js/WoltLabSuite/Core/Conversation/Ui/MarkAllAsRead.js +++ b/files/js/WoltLabSuite/Core/Conversation/Component/MarkAllAsRead.js @@ -2,28 +2,27 @@ * Marks all conversations as read. * * @author Marcel Werk - * @copyright 2001-2022 WoltLab GmbH + * @copyright 2001-2025 WoltLab GmbH * @license GNU Lesser General Public License - * @since 6.0 + * @since 6.2 */ -define(["require", "exports", "WoltLabSuite/Core/Ajax", "WoltLabSuite/Core/Component/Snackbar"], function (require, exports, Ajax_1, Snackbar_1) { +define(["require", "exports", "WoltLabSuite/Core/Ajax", "WoltLabSuite/Core/Component/Snackbar", "WoltLabSuite/Core/Helper/PromiseMutex"], function (require, exports, Ajax_1, Snackbar_1, PromiseMutex_1) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.setup = setup; async function markAllAsRead() { await (0, Ajax_1.dboAction)("markAllAsRead", "wcf\\data\\conversation\\ConversationAction").dispatch(); - document.querySelectorAll(".conversationList .new").forEach((el) => { - el.classList.remove("new"); + document.querySelectorAll(".conversationList__item__markAsRead").forEach((element) => { + element.remove(); }); document.querySelector("#unreadConversations .badgeUpdate")?.remove(); (0, Snackbar_1.showDefaultSuccessSnackbar)(); } function setup() { - document.querySelectorAll(".markAllAsReadButton").forEach((el) => { - el.addEventListener("click", (event) => { - event.preventDefault(); - void markAllAsRead(); - }); + document.querySelectorAll(".markAllAsReadButton").forEach((element) => { + element.addEventListener("click", (0, PromiseMutex_1.promiseMutex)(async () => { + await markAllAsRead(); + })); }); } }); diff --git a/files/js/WoltLabSuite/Core/Conversation/Component/MarkAsRead.js b/files/js/WoltLabSuite/Core/Conversation/Component/MarkAsRead.js new file mode 100644 index 00000000..872cda42 --- /dev/null +++ b/files/js/WoltLabSuite/Core/Conversation/Component/MarkAsRead.js @@ -0,0 +1,26 @@ +/** + * Handles the mark as read button for single conversations. + * + * @author Marcel Werk + * @copyright 2001-2025 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.2 + */ +define(["require", "exports", "WoltLabSuite/Core/Ajax", "WoltLabSuite/Core/Component/Snackbar", "WoltLabSuite/Core/Helper/PromiseMutex", "WoltLabSuite/Core/Helper/Selector"], function (require, exports, Ajax_1, Snackbar_1, PromiseMutex_1, Selector_1) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.setup = setup; + async function markAsRead(button) { + const objectId = parseInt(button.dataset.objectId, 10); + await (0, Ajax_1.dboAction)("markAsRead", "wcf\\data\\conversation\\ConversationAction").objectIds([objectId]).dispatch(); + button.remove(); + (0, Snackbar_1.showDefaultSuccessSnackbar)(); + } + function setup() { + (0, Selector_1.wheneverFirstSeen)(".conversationList__item__markAsRead", (element) => { + element.addEventListener("click", (0, PromiseMutex_1.promiseMutex)(async () => { + await markAsRead(element); + })); + }); + } +}); diff --git a/files/js/WoltLabSuite/Core/Conversation/Ui/MarkAsRead.js b/files/js/WoltLabSuite/Core/Conversation/Ui/MarkAsRead.js deleted file mode 100644 index d4f3db17..00000000 --- a/files/js/WoltLabSuite/Core/Conversation/Ui/MarkAsRead.js +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Handles the mark as read button for single conversations. - * - * @author Marcel Werk - * @copyright 2001-2022 WoltLab GmbH - * @license GNU Lesser General Public License - * @since 6.0 - */ -define(["require", "exports", "WoltLabSuite/Core/Ajax"], function (require, exports, Ajax_1) { - "use strict"; - Object.defineProperty(exports, "__esModule", { value: true }); - exports.setup = setup; - const unreadConversations = new WeakSet(); - async function markAsRead(conversation) { - const conversationId = parseInt(conversation.dataset.conversationId, 10); - await (0, Ajax_1.dboAction)("markAsRead", "wcf\\data\\conversation\\ConversationAction").objectIds([conversationId]).dispatch(); - conversation.classList.remove("new"); - conversation.querySelector(".columnAvatar p")?.removeAttribute("title"); - } - function setup() { - document.querySelectorAll(".conversationList .new .columnAvatar").forEach((el) => { - if (!unreadConversations.has(el)) { - unreadConversations.add(el); - el.addEventListener("dblclick", (event) => { - event.preventDefault(); - const conversation = el.closest(".conversation"); - if (!conversation.classList.contains("new")) { - return; - } - void markAsRead(conversation); - }, { once: true }); - } - }); - } -}); diff --git a/files/js/WoltLabSuite/Core/Conversation/Ui/Message/InlineEditor.js b/files/js/WoltLabSuite/Core/Conversation/Ui/Message/InlineEditor.js index a8823381..238d2b85 100644 --- a/files/js/WoltLabSuite/Core/Conversation/Ui/Message/InlineEditor.js +++ b/files/js/WoltLabSuite/Core/Conversation/Ui/Message/InlineEditor.js @@ -3,7 +3,7 @@ * * @author Olaf Braun * @copyright 2001-2025 WoltLab GmbH - * @license WoltLab License + * @license GNU Lesser General Public License * @since 6.2 */ define(["require", "exports", "tslib", "WoltLabSuite/Core/Ui/Message/InlineEditor"], function (require, exports, tslib_1, InlineEditor_1) { diff --git a/files/lib/data/conversation/Conversation.class.php b/files/lib/data/conversation/Conversation.class.php index 75ce0b41..a2e6d2dc 100644 --- a/files/lib/data/conversation/Conversation.class.php +++ b/files/lib/data/conversation/Conversation.class.php @@ -2,17 +2,19 @@ namespace wcf\data\conversation; +use wcf\data\CollectionDatabaseObject; +use wcf\data\conversation\label\ConversationLabel; use wcf\data\conversation\message\ConversationMessage; -use wcf\data\DatabaseObject; +use wcf\data\conversation\participant\ConversationParticipant; use wcf\data\IPopoverObject; use wcf\data\user\group\UserGroup; use wcf\data\user\ignore\UserIgnore; use wcf\data\user\UserProfile; -use wcf\system\cache\runtime\ConversationMessageRuntimeCache; use wcf\system\cache\runtime\UserProfileRuntimeCache; use wcf\system\conversation\ConversationHandler; use wcf\system\database\util\PreparedStatementConditionBuilder; use wcf\system\exception\UserInputException; +use wcf\system\file\processor\ImageData; use wcf\system\request\IRouteController; use wcf\system\request\LinkHandler; use wcf\system\user\storage\UserStorageHandler; @@ -38,7 +40,6 @@ * @property-read int $replies number of replies on the conversation * @property-read int $attachments total number of attachments in all messages of the conversation * @property-read int $participants number of participants of the conversations - * @property-read string $participantSummary serialized data of five of the conversation participants (sorted by username) * @property-read int $participantCanInvite is `1` if participants can invite other users to join the conversation, otherwise `0` * @property-read int $isClosed is `1` if the conversation is closed for new messages, otherwise `0` * @property-read int $isDraft is `1` if the conversation is a draft only, thus not sent to any participant, otherwise `0` @@ -50,8 +51,11 @@ * @property-read int|null $joinedAt timestamp at which the user joined the conversation; is `null` if the conversation has not been fetched via `UserConversationList` * @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` * @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` + * @property-read int|null $leftByOwnChoice + * + * @extends CollectionDatabaseObject */ -class Conversation extends DatabaseObject implements IPopoverObject, IRouteController +class Conversation extends CollectionDatabaseObject implements IPopoverObject, IRouteController { /** * default participation state @@ -71,18 +75,6 @@ class Conversation extends DatabaseObject implements IPopoverObject, IRouteContr */ public const STATE_LEFT/*4DEAD*/ = 2; - /** - * true if the current user can add users without limitations - * @var bool - */ - protected $canAddUnrestricted; - - /** - * true if the current user is an active participant of this conversation - * @var bool - */ - protected $isActiveParticipant; - /** * @inheritDoc */ @@ -151,35 +143,27 @@ public function setLastMessage(?int $userID, string $username, int $time) } /** - * Loads participation data for given user id (default: current user) on runtime. - * You should use Conversation::getUserConversation() instead if possible. - * - * @return void + * @since 6.2 */ - public function loadUserParticipation(?int $userID = null) + public function getTeaser(): string { - if ($userID === null) { - $userID = WCF::getUser()->userID; - } + return $this->getFirstMessage()->getTeaser(); + } - $sql = "SELECT * - FROM wcf1_conversation_to_user - WHERE participantID = ? - AND conversationID = ?"; - $statement = WCF::getDB()->prepare($sql); - $statement->execute([$userID, $this->conversationID]); - $row = $statement->fetchArray(); - if ($row !== false) { - $this->data = \array_merge($this->data, $row); - } + /** + * @since 6.2 + */ + public function getTeaserImage(): ?ImageData + { + return $this->getFirstMessage()->getTeaserImage(); } /** * Returns a specific user conversation. * - * @return ?Conversation + * @deprecated 6.2 Use `Conversation::getParticipant()` or `Conversation::getOtherParticipant()` instead. */ - public static function getUserConversation(int $conversationID, int $userID) + public static function getUserConversation(int $conversationID, int $userID): ?Conversation { $sql = "SELECT conversation_to_user.*, conversation.* FROM wcf1_conversation conversation @@ -201,9 +185,10 @@ public static function getUserConversation(int $conversationID, int $userID) * Returns a list of user conversations. * * @param int[] $conversationIDs - * @return Conversation[] + * @return array + * @deprecated 6.2 */ - public static function getUserConversations(array $conversationIDs, int $userID) + public static function getUserConversations(array $conversationIDs, int $userID): array { $conditionBuilder = new PreparedStatementConditionBuilder(); $conditionBuilder->add('conversation.conversationID IN (?)', [$conversationIDs]); @@ -280,32 +265,12 @@ public function canAddParticipants(): bool */ public function canAddParticipantsUnrestricted(): bool { - if ($this->canAddUnrestricted === null) { - $this->canAddUnrestricted = false; - if ($this->isActiveParticipant()) { - $sql = "SELECT joinedAt - FROM wcf1_conversation_to_user - WHERE conversationID = ? - AND participantID = ?"; - $statement = WCF::getDB()->prepare($sql); - $statement->execute([ - $this->conversationID, - WCF::getUser()->userID, - ]); - $joinedAt = $statement->fetchSingleColumn(); - - if ($joinedAt !== false && $joinedAt == 0) { - $this->canAddUnrestricted = true; - } - } - } - - return $this->canAddUnrestricted; + return $this->joinedAt === 0; } public function getFirstMessage(): ?ConversationMessage { - return ConversationMessageRuntimeCache::getInstance()->getObject($this->firstMessageID); + return $this->getCollection()->getFirstMessage($this); } /** @@ -313,7 +278,7 @@ public function getFirstMessage(): ?ConversationMessage * * @return int[] */ - public function getParticipantIDs(bool $excludeLeftParticipants = false) + public function getParticipantIDs(bool $excludeLeftParticipants = false): array { $conditions = new PreparedStatementConditionBuilder(); $conditions->add("conversationID = ?", [$this->conversationID]); @@ -336,7 +301,7 @@ public function getParticipantIDs(bool $excludeLeftParticipants = false) * * @return string[] */ - public function getParticipantNames(bool $excludeSelf = false, bool $leftByOwnChoice = false, bool $isAuthor = false) + public function getParticipantNames(bool $excludeSelf = false, bool $leftByOwnChoice = false, bool $isAuthor = false): array { $conditions = new PreparedStatementConditionBuilder(); $conditions->add("conversationID = ?", [$this->conversationID]); @@ -363,6 +328,8 @@ public function getParticipantNames(bool $excludeSelf = false, bool $leftByOwnCh /** * Returns false if the active user is the last participant of this conversation. + * + * @deprecated 6.2 No longer in use. */ public function hasOtherParticipants(): bool { @@ -383,22 +350,7 @@ public function hasOtherParticipants(): bool */ public function isActiveParticipant(): bool { - if ($this->isActiveParticipant === null) { - $sql = "SELECT leftAt - FROM wcf1_conversation_to_user - WHERE conversationID = ? - AND participantID = ?"; - $statement = WCF::getDB()->prepare($sql); - $statement->execute([ - $this->conversationID, - WCF::getUser()->userID, - ]); - $leftAt = $statement->fetchSingleColumn(); - - $this->isActiveParticipant = ($leftAt !== false && $leftAt == 0); - } - - return $this->isActiveParticipant; + return $this->leftAt === 0; } /** @@ -468,6 +420,7 @@ public static function isParticipant(array $conversationIDs, ?int $userID = null * @param int[] $existingParticipants * @return list * @throws UserInputException + * @deprecated 6.2 No longer in use. */ public static function validateParticipants( array|string $participants, @@ -527,6 +480,7 @@ public static function validateParticipants( * @param string[]|string $participants * @param int[] $existingParticipants * @return list + * @deprecated 6.2 No longer in use. */ public static function validateGroupParticipants( array|string $participants, @@ -590,6 +544,7 @@ public static function validateGroupParticipants( * * @return void * @throws UserInputException + * @deprecated 6.2 Use `TConversationForm::getParticipantsValidator()` instead. */ public static function validateParticipant(UserProfile $user, string $field = 'participants') { @@ -624,4 +579,79 @@ public static function validateParticipant(UserProfile $user, string $field = 'p } } } + + /** + * @since 6.2 + */ + public function getUserProfile(): UserProfile + { + return $this->getCollection()->getUserProfile($this); + } + + /** + * @since 6.2 + */ + public function getLastPosterProfile(): UserProfile + { + return $this->getCollection()->getLastPosterProfile($this); + } + + /** + * @return array + * @since 6.2 + */ + public function getAssignedLabels(): array + { + return $this->getCollection()->getAssignedLabels($this); + } + + /** + * @return list + * @since 6.2 + */ + public function getParticipantSummary(): array + { + return $this->getCollection()->getParticipantSummary($this); + } + + /** + * Provides information about the active user's participation in this conversation. + * Returns null if the active user is not a participant in this conversation. + * + * @since 6.2 + */ + public function getParticipant(): ?ConversationParticipant + { + return $this->getCollection()->getParticipant($this); + } + + /** + * Provides information about the given user's participation in this conversation. + * Returns null if the given user is not a participant in this conversation. + * + * @since 6.2 + */ + public function getOtherParticipant(int $userID): ?ConversationParticipant + { + return ConversationParticipant::getParticipant($this->conversationID, $userID); + } + + #[\Override] + public function __get($name) + { + return match ($name) { + 'participantID', 'hideConversation', 'isInvisible', 'lastVisitTime', + 'joinedAt', 'leftAt', 'lastMessageID', 'leftByOwnChoice' => $this->getParticipantData($name), + default => parent::__get($name), + }; + } + + private function getParticipantData(string $name): mixed + { + if (\array_key_exists($name, $this->data)) { + return $this->data[$name]; + } + + return $this->getParticipant()?->$name; + } } diff --git a/files/lib/data/conversation/ConversationAction.class.php b/files/lib/data/conversation/ConversationAction.class.php index b4f635c4..aa091764 100644 --- a/files/lib/data/conversation/ConversationAction.class.php +++ b/files/lib/data/conversation/ConversationAction.class.php @@ -6,7 +6,9 @@ use wcf\data\conversation\message\ConversationMessageAction; use wcf\data\conversation\message\ConversationMessageList; use wcf\data\IVisitableObjectAction; +use wcf\data\user\UserProfile; use wcf\page\ConversationPage; +use wcf\system\cache\runtime\UserProfileRuntimeCache; use wcf\system\conversation\ConversationHandler; use wcf\system\database\util\PreparedStatementConditionBuilder; use wcf\system\exception\IllegalLinkException; @@ -95,9 +97,6 @@ public function create() UserStorageHandler::getInstance()->reset([$data['userID']], 'conversationCount'); } - // update participant summary - $conversationEditor->updateParticipantSummary(); - // create message $messageData = $this->parameters['messageData'] ?? []; $messageData['conversationID'] = $conversation->conversationID; @@ -230,7 +229,6 @@ public function update() (!empty($this->parameters['invisibleParticipants']) ? $this->parameters['invisibleParticipants'] : []), (!empty($this->parameters['visibility']) ? $this->parameters['visibility'] : 'all') ); - $conversation->updateParticipantSummary(); // check if new participants have been added $newParticipantIDs = \array_diff(\array_merge( @@ -522,14 +520,26 @@ public function getConversations(): array UserStorageHandler::getInstance()->reset([WCF::getUser()->userID], 'unreadConversationCount'); } - $conversations = \array_map(static function (ViewableConversation $conversation) { + foreach ($unreadConversationList->getObjects() as $conversation) { + if ($conversation->otherParticipantID) { + UserProfileRuntimeCache::getInstance()->cacheObjectID($conversation->otherParticipantID); + } + } + + $conversations = \array_map(static function (Conversation $conversation) { if ($conversation->userID === WCF::getUser()->userID) { if ($conversation->participants > 1) { $image = FontAwesomeIcon::fromValues('users')->toHtml(48); - $usernames = \array_column($conversation->getParticipantSummary(), 'username'); + $usernames = \array_map(static fn($user) => $user->username, $conversation->getParticipantSummary()); } else { - $image = $conversation->getOtherParticipantProfile()->getAvatar()->getImageTag(48); - $usernames = [$conversation->getOtherParticipantProfile()->username]; + if ($conversation->otherParticipantID) { + $userProfile = UserProfileRuntimeCache::getInstance()->getObject($conversation->otherParticipantID); + } else { + $userProfile = UserProfile::getGuestUserProfile($conversation->otherParticipant); + } + + $image = $userProfile->getAvatar()->getImageTag(48); + $usernames = [$userProfile->username]; } } else { if ($conversation->participants > 1) { diff --git a/files/lib/data/conversation/ConversationCollection.class.php b/files/lib/data/conversation/ConversationCollection.class.php new file mode 100644 index 00000000..1016b50b --- /dev/null +++ b/files/lib/data/conversation/ConversationCollection.class.php @@ -0,0 +1,234 @@ + + * @since 6.2 + * + * @extends DatabaseObjectCollection + */ +class ConversationCollection extends DatabaseObjectCollection +{ + /** + * @var array> + */ + private array $assignedLabels; + + /** + * @var array> + */ + private array $participantSummaries; + + /** + * @var array + */ + private array $participants; + + private bool $userProfilesLoaded = false; + private bool $firstMessagesLoaded = false; + + public function getUserProfile(Conversation $conversation): UserProfile + { + $this->loadUserProfiles(); + + if ($conversation->userID) { + return UserProfileRuntimeCache::getInstance()->getObject($conversation->userID); + } else { + return UserProfile::getGuestUserProfile($conversation->username); + } + } + + public function getLastPosterProfile(Conversation $conversation): UserProfile + { + $this->loadUserProfiles(); + + if ($conversation->lastPosterID) { + return UserProfileRuntimeCache::getInstance()->getObject($conversation->lastPosterID); + } else { + return UserProfile::getGuestUserProfile($conversation->lastPoster); + } + } + + private function loadUserProfiles(): void + { + if ($this->userProfilesLoaded) { + return; + } + + $this->userProfilesLoaded = true; + + $userIDs = []; + foreach ($this->getObjects() as $object) { + if ($object->userID) { + $userIDs[] = $object->userID; + } + if ($object->lastPosterID) { + $userIDs[] = $object->lastPosterID; + } + } + + if ($userIDs !== []) { + UserProfileRuntimeCache::getInstance()->cacheObjectIDs($userIDs); + } + } + + /** + * @return array + */ + public function getAssignedLabels(Conversation $conversation): array + { + $this->loadAssignedLabels(); + + return $this->assignedLabels[$conversation->getObjectID()] ?? []; + } + + private function loadAssignedLabels(): void + { + if (isset($this->assignedLabels)) { + return; + } + + $this->assignedLabels = []; + $labels = ConversationLabel::getUserLabels(); + if ($labels === []) { + return; + } + + $conditions = new PreparedStatementConditionBuilder(); + $conditions->add("conversationID IN (?)", [$this->getObjectIDs()]); + $conditions->add("labelID IN (?)", [\array_keys($labels)]); + + $sql = "SELECT labelID, conversationID + FROM wcf1_conversation_label_to_object + " . $conditions; + $statement = WCF::getDB()->prepare($sql); + $statement->execute($conditions->getParameters()); + while ($row = $statement->fetchArray()) { + $this->assignedLabels[$row['conversationID']][$row['labelID']] = $labels[$row['labelID']]; + } + } + + public function getFirstMessage(Conversation $conversation): ?ConversationMessage + { + $this->loadFirstMessages(); + + return ConversationMessageRuntimeCache::getInstance()->getObject($conversation->firstMessageID); + } + + private function loadFirstMessages(): void + { + if ($this->firstMessagesLoaded) { + return; + } + + $this->firstMessagesLoaded = true; + + $messageIDs = []; + foreach ($this->getObjects() as $conversation) { + if ($conversation->firstMessageID) { + $messageIDs[] = $conversation->firstMessageID; + } + } + + if ($messageIDs !== []) { + ConversationMessageRuntimeCache::getInstance()->cacheObjectIDs($messageIDs); + } + } + + /** + * @return list + */ + public function getParticipantSummary(Conversation $conversation): array + { + $this->loadParticipantSummaries(); + + return $this->participantSummaries[$conversation->getObjectID()] ?? []; + } + + private function loadParticipantSummaries(): void + { + if (isset($this->participantSummaries)) { + return; + } + + $this->participantSummaries = []; + + $conditions = new PreparedStatementConditionBuilder(); + $conditions->add("conversationID IN (?)", [$this->getObjectIDs()]); + $conditions->add("participantID NOT IN (SELECT userID FROM wcf1_conversation WHERE conversationID = conversation_to_user.conversationID)"); + $conditions->add("isInvisible = ?", [0]); + + $sql = "SELECT conversationID, userID, username + FROM ( + SELECT conversationID, participantID AS userID, username, + ROW_NUMBER() OVER (PARTITION BY conversationID ORDER BY username) AS row_num + FROM wcf1_conversation_to_user conversation_to_user + {$conditions} + ) AS subselect + WHERE subselect.row_num <= ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute( + \array_merge($conditions->getParameters(), [5]) + ); + + $rows = $statement->fetchAll(\PDO::FETCH_ASSOC); + foreach ($rows as $row) { + if ($row['userID']) { + UserProfileRuntimeCache::getInstance()->cacheObjectID($row['userID']); + } + } + foreach ($rows as $row) { + if (!isset($this->participantSummaries[$row['conversationID']])) { + $this->participantSummaries[$row['conversationID']] = []; + } + + if ($row['userID']) { + $this->participantSummaries[$row['conversationID']][] = UserProfileRuntimeCache::getInstance()->getObject($row['userID']); + } else { + $this->participantSummaries[$row['conversationID']][] = UserProfile::getGuestUserProfile($row['username']); + } + } + } + + public function getParticipant(Conversation $conversation): ?ConversationParticipant + { + $this->loadParticipants(); + + return $this->participants[$conversation->getObjectID()] ?? null; + } + + private function loadParticipants(): void + { + if (isset($this->participants)) { + return; + } + + $this->participants = []; + + $list = new ConversationParticipantList(); + $list->getConditionBuilder()->add("conversationID IN (?)", [$this->getObjectIDs()]); + $list->getConditionBuilder()->add("participantID = ?", [WCF::getUser()->userID]); + $list->readObjects(); + + foreach ($list->getObjects() as $participant) { + $this->participants[$participant->conversationID] = $participant; + } + } +} diff --git a/files/lib/data/conversation/ConversationEditor.class.php b/files/lib/data/conversation/ConversationEditor.class.php index a088b53f..0aca5a81 100644 --- a/files/lib/data/conversation/ConversationEditor.class.php +++ b/files/lib/data/conversation/ConversationEditor.class.php @@ -149,25 +149,6 @@ public function updateParticipantCount() ]); } - /** - * Updates the participant summary of this conversation. - * - * @return void - */ - public function updateParticipantSummary() - { - $sql = "SELECT participantID AS userID, hideConversation, username - FROM wcf1_conversation_to_user - WHERE conversationID = ? - AND participantID <> ? - AND isInvisible = 0 - ORDER BY username"; - $statement = WCF::getDB()->prepare($sql, 5); - $statement->execute([$this->conversationID, $this->userID]); - - $this->update(['participantSummary' => \serialize($statement->fetchAll(\PDO::FETCH_ASSOC))]); - } - /** * Removes a participant from this conversation. * @@ -265,24 +246,6 @@ public function updateLastMessage() ]); } - /** - * Updates the participant summary of the given conversations. - * - * @param int[] $conversationIDs - * @return void - */ - public static function updateParticipantSummaries(array $conversationIDs) - { - $conversationList = new ConversationList(); - $conversationList->setObjectIDs($conversationIDs); - $conversationList->readObjects(); - - foreach ($conversationList as $conversation) { - $editor = new self($conversation); - $editor->updateParticipantSummary(); - } - } - /** * Updates the participant counts of the given conversations. * diff --git a/files/lib/data/conversation/UserConversationList.class.php b/files/lib/data/conversation/UserConversationList.class.php index 2e830c93..9fa10619 100644 --- a/files/lib/data/conversation/UserConversationList.class.php +++ b/files/lib/data/conversation/UserConversationList.class.php @@ -2,54 +2,34 @@ namespace wcf\data\conversation; -use wcf\data\conversation\label\ConversationLabel; -use wcf\data\conversation\label\ConversationLabelList; -use wcf\system\cache\runtime\ConversationMessageRuntimeCache; -use wcf\system\cache\runtime\UserProfileRuntimeCache; use wcf\system\database\util\PreparedStatementConditionBuilder; use wcf\system\WCF; /** - * Represents a list of conversations. + * Represents a list of conversations in which a specific user is a participant. * - * @author Marcel Werk - * @copyright 2001-2019 WoltLab GmbH - * @license GNU Lesser General Public License - * - * @extends ConversationList + * @author Marcel Werk + * @copyright 2001-2025 WoltLab GmbH + * @license GNU Lesser General Public License */ class UserConversationList extends ConversationList { /** - * list of available filters * @var string[] */ - public static $availableFilters = ['hidden', 'draft', 'outbox']; - - /** - * active filter - * @var string - */ - public $filter = ''; - - /** - * @inheritDoc - */ - public $decoratorClassName = ViewableConversation::class; - - /** - * Creates a new UserConversationList - */ - public function __construct(?int $userID = null, string $filter = '', ?int $labelID = null) - { - if (!$userID) { + public static array $availableFilters = ['hidden', 'draft', 'outbox']; + + public function __construct( + ?int $userID = null, + public readonly string $filter = '', + ?int $labelID = null + ) { + if ($userID === null) { $userID = WCF::getUser()->userID; } parent::__construct(); - $this->filter = $filter; - // apply filter if ($this->filter === 'draft') { $this->getConditionBuilder()->add('conversation.userID = ?', [$userID]); @@ -106,9 +86,7 @@ public function __construct(?int $userID = null, string $filter = '', ?int $labe } } - /** - * @inheritDoc - */ + #[\Override] public function countObjects() { if ($this->filter == 'draft') { @@ -126,9 +104,7 @@ public function countObjects() return $row['count']; } - /** - * @inheritDoc - */ + #[\Override] public function readObjectIDs() { if ($this->filter === 'draft') { @@ -152,9 +128,7 @@ public function readObjectIDs() $this->objectIDs = $statement->fetchAll(\PDO::FETCH_COLUMN); } - /** - * @inheritDoc - */ + #[\Override] public function readObjects() { if ($this->objectIDs === null) { @@ -163,99 +137,48 @@ public function readObjects() parent::readObjects(); - if (!empty($this->objects)) { - $messageIDs = []; - foreach ($this->objects as $conversation) { - if ($conversation->lastMessageID) { - $messageIDs[] = $conversation->lastMessageID; - } - } - if (!empty($messageIDs)) { - $conditions = new PreparedStatementConditionBuilder(); - $conditions->add("messageID IN (?)", [$messageIDs]); - $sql = "SELECT messageID, userID, username, time - FROM wcf1_conversation_message - " . $conditions; - $statement = WCF::getDB()->prepare($sql); - $statement->execute($conditions->getParameters()); - $messageData = []; - while ($row = $statement->fetchArray()) { - $messageData[$row['messageID']] = $row; - } - - foreach ($this->objects as $conversation) { - if ($conversation->lastMessageID) { - $data = (isset($messageData[$conversation->lastMessageID])) ? $messageData[$conversation->lastMessageID] : null; - if ($data !== null) { - $conversation->setLastMessage($data['userID'], $data['username'], $data['time']); - } else { - $conversation->setLastMessage(null, '', 0); - } - } - } - } - - $labels = $this->loadLabelAssignments(); - - $userIDs = $messageIDs = []; - foreach ($this->objects as $conversationID => $conversation) { - if (isset($labels[$conversationID])) { - foreach ($labels[$conversationID] as $label) { - $conversation->assignLabel($label); - } - } - - if ($conversation->userID) { - $userIDs[] = $conversation->userID; - } - if ($conversation->lastPosterID) { - $userIDs[] = $conversation->lastPosterID; - } + $this->setLastMessages(); + } - if ($conversation->firstMessageID) { - $messageIDs[] = $conversation->firstMessageID; - } - } + protected function setLastMessages(): void + { + if ($this->getObjects() === []) { + return; + } - if ($userIDs !== []) { - UserProfileRuntimeCache::getInstance()->cacheObjectIDs($userIDs); - } - if ($messageIDs !== []) { - ConversationMessageRuntimeCache::getInstance()->cacheObjectIDs($userIDs); + $messageIDs = []; + foreach ($this->getObjects() as $conversation) { + if ($conversation->lastMessageID) { + $messageIDs[] = $conversation->lastMessageID; } } - } - /** - * Returns label assignments per conversation. - * - * @return ConversationLabel[][] - */ - protected function loadLabelAssignments() - { - $labels = ConversationLabel::getUserLabels(); - if ($labels === []) { - return []; + if ($messageIDs === []) { + return; } - $conditions = new PreparedStatementConditionBuilder(); - $conditions->add("conversationID IN (?)", [\array_keys($this->objects)]); - $conditions->add("labelID IN (?)", [\array_keys($labels)]); - $sql = "SELECT labelID, conversationID - FROM wcf1_conversation_label_to_object - " . $conditions; + $conditions = new PreparedStatementConditionBuilder(); + $conditions->add("messageID IN (?)", [$messageIDs]); + $sql = "SELECT messageID, userID, username, time + FROM wcf1_conversation_message + " . $conditions; $statement = WCF::getDB()->prepare($sql); $statement->execute($conditions->getParameters()); - $data = []; + $messageData = []; while ($row = $statement->fetchArray()) { - if (!isset($data[$row['conversationID']])) { - $data[$row['conversationID']] = []; - } - - $data[$row['conversationID']][$row['labelID']] = $labels[$row['labelID']]; + $messageData[$row['messageID']] = $row; } - return $data; + foreach ($this->objects as $conversation) { + if ($conversation->lastMessageID) { + $data = (isset($messageData[$conversation->lastMessageID])) ? $messageData[$conversation->lastMessageID] : null; + if ($data !== null) { + $conversation->setLastMessage($data['userID'], $data['username'], $data['time']); + } else { + $conversation->setLastMessage(null, '', 0); + } + } + } } } diff --git a/files/lib/data/conversation/ViewableConversation.class.php b/files/lib/data/conversation/ViewableConversation.class.php deleted file mode 100644 index 424322ec..00000000 --- a/files/lib/data/conversation/ViewableConversation.class.php +++ /dev/null @@ -1,210 +0,0 @@ - - * - * @mixin Conversation - * @property-read ?int $otherParticipantID - * @property-read ?string $otherParticipant - * @extends DatabaseObjectDecorator - */ -class ViewableConversation extends DatabaseObjectDecorator -{ - /** - * participant summary - * @var User[]|null - */ - protected $__participantSummary; - - /** - * user profile object - * @var ?UserProfile - */ - protected $userProfile; - - /** - * last poster's profile - * @var ?UserProfile - */ - protected $lastPosterProfile; - - /** - * other participant's profile - * @var ?UserProfile - */ - protected $otherParticipantProfile; - - /** - * list of assigned labels - * @var ConversationLabel[] - */ - protected $labels = []; - - /** - * @inheritDoc - */ - protected static $baseClass = Conversation::class; - - /** - * Returns the user profile object. - * - * @return UserProfile - */ - public function getUserProfile() - { - if ($this->userProfile === null) { - if ($this->userID) { - $this->userProfile = UserProfileRuntimeCache::getInstance()->getObject($this->userID); - } else { - $this->userProfile = UserProfile::getGuestUserProfile($this->username); - } - } - - return $this->userProfile; - } - - /** - * Returns the last poster's profile object. - * - * @return UserProfile - */ - public function getLastPosterProfile() - { - if ($this->lastPosterProfile === null) { - if ($this->lastPosterID) { - $this->lastPosterProfile = UserProfileRuntimeCache::getInstance()->getObject($this->lastPosterID); - } else { - $this->lastPosterProfile = UserProfile::getGuestUserProfile($this->lastPoster); - } - } - - return $this->lastPosterProfile; - } - - /** - * Returns the number of pages in this conversation. - * - * @return int - */ - public function getPages() - { - /** @noinspection PhpUndefinedFieldInspection */ - if (WCF::getUser()->conversationMessagesPerPage) { - /** @noinspection PhpUndefinedFieldInspection */ - $messagesPerPage = WCF::getUser()->conversationMessagesPerPage; - } else { - $messagesPerPage = CONVERSATION_MESSAGES_PER_PAGE; - } - - return (int)\ceil(($this->replies + 1) / $messagesPerPage); - } - - /** - * Returns a summary of the participants. - * - * @return User[] - */ - public function getParticipantSummary() - { - if ($this->__participantSummary === null) { - $this->__participantSummary = []; - - if ($this->participantSummary) { - $data = \unserialize($this->participantSummary); - if ($data !== false) { - foreach ($data as $userData) { - $this->__participantSummary[] = new User(null, [ - 'userID' => $userData['userID'], - 'username' => $userData['username'], - 'hideConversation' => $userData['hideConversation'], - ]); - } - } - } - } - - return $this->__participantSummary; - } - - /** - * Returns the other participant's profile object. - * - * @return UserProfile - */ - public function getOtherParticipantProfile() - { - if ($this->otherParticipantProfile === null) { - if ($this->otherParticipantID) { - $this->otherParticipantProfile = UserProfileRuntimeCache::getInstance() - ->getObject($this->otherParticipantID); - } else { - $this->otherParticipantProfile = UserProfile::getGuestUserProfile($this->otherParticipant); - } - } - - return $this->otherParticipantProfile; - } - - /** - * Assigns a label. - * - * @return void - */ - public function assignLabel(ConversationLabel $label) - { - $this->labels[$label->labelID] = $label; - } - - /** - * Returns a list of assigned labels. - * - * @return ConversationLabel[] - */ - public function getAssignedLabels() - { - return $this->labels; - } - - /** - * Converts a conversation into a viewable conversation. - * - * @return ViewableConversation - */ - public static function getViewableConversation(Conversation $conversation, ?ConversationLabelList $labelList = null) - { - $conversation = new self($conversation); - - $labels = ConversationLabel::getUserLabels(); - if ($labels !== []) { - $conditions = new PreparedStatementConditionBuilder(); - $conditions->add("conversationID = ?", [$conversation->conversationID]); - $conditions->add("labelID IN (?)", [\array_keys($labels)]); - - $sql = "SELECT labelID - FROM wcf1_conversation_label_to_object - " . $conditions; - $statement = WCF::getDB()->prepare($sql); - $statement->execute($conditions->getParameters()); - while ($row = $statement->fetchArray()) { - $conversation->assignLabel($labels[$row['labelID']]); - } - } - - return $conversation; - } -} diff --git a/files/lib/data/conversation/message/ConversationMessage.class.php b/files/lib/data/conversation/message/ConversationMessage.class.php index 214fdfcc..bfb720ea 100644 --- a/files/lib/data/conversation/message/ConversationMessage.class.php +++ b/files/lib/data/conversation/message/ConversationMessage.class.php @@ -3,14 +3,13 @@ namespace wcf\data\conversation\message; use wcf\data\attachment\GroupedAttachmentList; +use wcf\data\CollectionDatabaseObject; use wcf\data\conversation\Conversation; -use wcf\data\DatabaseObject; -use wcf\data\IEmbeddedMessageObject; use wcf\data\IMessage; -use wcf\data\object\type\ObjectTypeCache; use wcf\data\TUserContent; +use wcf\data\user\UserProfile; +use wcf\system\file\processor\ImageData; use wcf\system\html\output\HtmlOutputProcessor; -use wcf\system\message\embedded\object\MessageEmbeddedObjectManager; use wcf\system\request\LinkHandler; use wcf\system\WCF; use wcf\util\StringUtil; @@ -34,8 +33,10 @@ * @property-read int $lastEditTime timestamp at which the conversation message has been edited the last time * @property-read int $editCount number of times the conversation message has been edited * @property-read int $hasEmbeddedObjects number of embedded objects in the conversation message + * + * @extends CollectionDatabaseObject */ -class ConversationMessage extends DatabaseObject implements IMessage, IEmbeddedMessageObject +class ConversationMessage extends CollectionDatabaseObject implements IMessage { use TUserContent; @@ -50,6 +51,8 @@ class ConversationMessage extends DatabaseObject implements IMessage, IEmbeddedM */ public function getFormattedMessage(): string { + $this->getCollection()->loadEmbeddedObjects(); + $processor = new HtmlOutputProcessor(); $processor->process($this->message, 'com.woltlab.wcf.conversation.message', $this->messageID); @@ -61,6 +64,8 @@ public function getFormattedMessage(): string */ public function getSimplifiedFormattedMessage(): string { + $this->getCollection()->loadEmbeddedObjects(); + $processor = new HtmlOutputProcessor(); $processor->setOutputType('text/simplified-html'); $processor->process($this->message, 'com.woltlab.wcf.conversation.message', $this->messageID); @@ -109,12 +114,7 @@ public function getExcerpt($maxLength = 255): string */ public function getMailText(string $mimeType = 'text/plain'): string { - if ($this->hasEmbeddedObjects) { - MessageEmbeddedObjectManager::getInstance()->loadObjects( - 'com.woltlab.wcf.conversation.message', - [$this->messageID] - ); - } + $this->getCollection()->loadEmbeddedObjects(); switch ($mimeType) { case 'text/plain': @@ -131,29 +131,30 @@ public function getMailText(string $mimeType = 'text/plain'): string } /** - * Returns the conversation of this message. - * - * @return ?Conversation + * @since 6.2 */ - public function getConversation() + public function getTeaser(): string { - if ($this->conversation === null) { - $this->conversation = Conversation::getUserConversation($this->conversationID, WCF::getUser()->userID); - } + $this->getCollection()->loadEmbeddedObjects(); + + $processor = new HtmlOutputProcessor(); + $processor->setOutputType('text/plain'); + $processor->process($this->message, 'com.woltlab.wcf.conversation.message', $this->messageID); - return $this->conversation; + return StringUtil::truncate($processor->getHtml(), 255); } /** - * Sets the conversation of this message. - * - * @return void + * @since 6.2 */ - public function setConversation(Conversation $conversation) + public function getTeaserImage(): ?ImageData { - if ($this->conversationID == $conversation->conversationID) { - $this->conversation = $conversation; - } + return $this->getCollection()->getTeaserImage($this); + } + + public function getConversation(): ?Conversation + { + return $this->getCollection()->getConversation($this); } /** @@ -217,18 +218,11 @@ public function __toString(): string return $this->getFormattedMessage(); } - #[\Override] - public function loadEmbeddedObjects(): void + /** + * @since 6.2 + */ + public function getUserProfile(): UserProfile { - if ($this->hasEmbeddedObjects) { - ObjectTypeCache::getInstance() - ->getObjectTypeByName('com.woltlab.wcf.attachment.objectType', 'com.woltlab.wcf.conversation.message') - ->getProcessor() - ->cacheObjects([$this->messageID]); - MessageEmbeddedObjectManager::getInstance()->loadObjects( - 'com.woltlab.wcf.conversation.message', - [$this->messageID] - ); - } + return $this->getCollection()->getUserProfile($this); } } diff --git a/files/lib/data/conversation/message/ConversationMessageAction.class.php b/files/lib/data/conversation/message/ConversationMessageAction.class.php index d0655c4d..82e8319c 100644 --- a/files/lib/data/conversation/message/ConversationMessageAction.class.php +++ b/files/lib/data/conversation/message/ConversationMessageAction.class.php @@ -113,8 +113,7 @@ public function create() // update last message $conversationEditor->addMessage($message); - $userConversation = Conversation::getUserConversation($conversation->conversationID, $message->userID); - if ($userConversation !== null && $userConversation->isInvisible) { + if ($conversation->isInvisibleParticipant($message->userID)) { // make invisible participant visible $sql = "UPDATE wcf1_conversation_to_user SET isInvisible = 0 @@ -123,7 +122,6 @@ public function create() $statement = WCF::getDB()->prepare($sql); $statement->execute([$message->userID, $conversation->conversationID]); - $conversationEditor->updateParticipantSummary(); $conversationEditor->updateParticipantCount(); } @@ -495,7 +493,6 @@ public function validateContainer(DatabaseObject $container) if ($container->isClosed) { throw new PermissionDeniedException(); } - $container->loadUserParticipation(); if (!$container->canReply()) { throw new PermissionDeniedException(); } @@ -546,8 +543,7 @@ public function validateMessage(DatabaseObject $container, HtmlInputProcessor $h */ public function getMessageList(DatabaseObject $container, int $lastMessageTime) { - $messageList = new ViewableConversationMessageList(); - $messageList->setConversation($container); + $messageList = new ConversationMessageList(); $messageList->getConditionBuilder() ->add("conversation_message.conversationID = ?", [$container->conversationID]); $messageList->getConditionBuilder() diff --git a/files/lib/data/conversation/message/ConversationMessageCollection.class.php b/files/lib/data/conversation/message/ConversationMessageCollection.class.php new file mode 100644 index 00000000..755d2005 --- /dev/null +++ b/files/lib/data/conversation/message/ConversationMessageCollection.class.php @@ -0,0 +1,179 @@ + + * @since 6.2 + * + * @extends DatabaseObjectCollection + */ +class ConversationMessageCollection extends DatabaseObjectCollection +{ + /** + * @var array + */ + private array $conversations; + + /** + * @var array + */ + private array $teaserImages; + + private bool $embeddedObjectsLoaded = false; + private bool $userProfilesLoaded = false; + + public function getConversation(ConversationMessage $object): ?Conversation + { + $this->loadConversations(); + + return $this->conversations[$object->conversationID] ?? null; + } + + private function loadConversations(): void + { + if (isset($this->conversations)) { + return; + } + + $this->conversations = []; + $conversationIDs = \array_map(static fn($message) => $message->conversationID, $this->getObjects()); + $conversationIDs = \array_unique($conversationIDs); + + if ($conversationIDs !== []) { + $this->conversations = ConversationRuntimeCache::getInstance()->getObjects($conversationIDs); + } + } + + public function loadEmbeddedObjects(): void + { + if ($this->embeddedObjectsLoaded) { + return; + } + + $this->embeddedObjectsLoaded = true; + + // Add message objects to attachment object cache to save SQL queries. + ObjectTypeCache::getInstance() + ->getObjectTypeByName('com.woltlab.wcf.attachment.objectType', 'com.woltlab.wcf.conversation.message') + ->getProcessor() + ->setCachedObjects($this->getObjects()); + + $objectIDs = $this->getEmbeddedObjectIDs(); + if ($objectIDs === []) { + return; + } + + MessageEmbeddedObjectManager::getInstance() + ->loadObjects('com.woltlab.wcf.conversation.message', $objectIDs); + } + + public function getUserProfile(ConversationMessage $message): UserProfile + { + $this->loadUserProfiles(); + + if ($message->userID) { + return UserProfileRuntimeCache::getInstance()->getObject($message->userID); + } else { + return UserProfile::getGuestUserProfile($message->username); + } + } + + private function loadUserProfiles(): void + { + if ($this->userProfilesLoaded) { + return; + } + + $this->userProfilesLoaded = true; + + $userIDs = []; + foreach ($this->getObjects() as $object) { + if ($object->userID) { + $userIDs[] = $object->userID; + } + } + + if ($userIDs !== []) { + UserProfileRuntimeCache::getInstance()->cacheObjectIDs($userIDs); + } + } + + /** + * @return int[] + */ + private function getEmbeddedObjectIDs(): array + { + return \array_map( + fn($content) => $content->recordID, + \array_filter($this->getObjects(), fn($content) => $content->hasEmbeddedObjects) + ); + } + + public function getTeaserImage(ConversationMessage $message): ?ImageData + { + $this->loadTeaserImages(); + + return $this->teaserImages[$message->getObjectID()] ?? null; + } + + private function loadTeaserImages(): void + { + if (isset($this->teaserImages)) { + return; + } + + $this->teaserImages = []; + + $conditions = new PreparedStatementConditionBuilder(); + $conditions->add("objectTypeID = ?", [ObjectTypeCache::getInstance()->getObjectTypeIDByName( + 'com.woltlab.wcf.attachment.objectType', + 'com.woltlab.wcf.conversation.message' + )]); + $conditions->add("objectID IN (?)", [$this->getObjectIDs()]); + $conditions->add("fileID IN (SELECT fileID FROM wcf1_file WHERE mimeType IN (?))", [[ + 'image/gif', + 'image/jpeg', + 'image/png', + 'image/webp' + ]]); + + $sql = "SELECT objectID, fileID + FROM ( + SELECT objectID, fileID, + ROW_NUMBER() OVER (PARTITION BY objectID ORDER BY showOrder) AS row_num + FROM wcf1_attachment + {$conditions} + ) AS subselect + WHERE subselect.row_num <= ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute( + \array_merge($conditions->getParameters(), [1]) + ); + + $rows = $statement->fetchAll(\PDO::FETCH_ASSOC); + foreach ($rows as $row) { + FileRuntimeCache::getInstance()->cacheObjectID($row['fileID']); + } + + foreach ($rows as $row) { + $this->teaserImages[$row['objectID']] = FileRuntimeCache::getInstance()->getObject($row['fileID'])->getImageData(320, 200); + } + } +} diff --git a/files/lib/data/conversation/message/ConversationMessageList.class.php b/files/lib/data/conversation/message/ConversationMessageList.class.php index 331f4793..6b7573e0 100644 --- a/files/lib/data/conversation/message/ConversationMessageList.class.php +++ b/files/lib/data/conversation/message/ConversationMessageList.class.php @@ -2,15 +2,16 @@ namespace wcf\data\conversation\message; +use wcf\data\attachment\GroupedAttachmentList; use wcf\data\DatabaseObjectDecorator; use wcf\data\DatabaseObjectList; /** * Represents a list of conversation messages. * - * @author Marcel Werk - * @copyright 2001-2019 WoltLab GmbH - * @license GNU Lesser General Public License + * @author Marcel Werk + * @copyright 2001-2025 WoltLab GmbH + * @license GNU Lesser General Public License * * @template TDatabaseObject of ConversationMessage|DatabaseObjectDecorator = ConversationMessage * @extends DatabaseObjectList @@ -21,4 +22,71 @@ class ConversationMessageList extends DatabaseObjectList * @inheritDoc */ public $className = ConversationMessage::class; + + /** + * @since 6.2 + */ + protected ?GroupedAttachmentList $attachments; + + #[\Override] + public function readObjects() + { + if ($this->objectIDs === null) { + $this->readObjectIDs(); + } + + parent::readObjects(); + } + + /** + * @since 6.2 + */ + public function getMaxTime(): int + { + $maxTime = 0; + foreach ($this->getObjects() as $message) { + if ($message->time > $maxTime) { + $maxTime = $message->time; + } + } + + return $maxTime; + } + + /** + * @since 6.2 + */ + public function getAttachments(): ?GroupedAttachmentList + { + if (!isset($this->attachments)) { + $this->attachments = null; + $attachmentObjectIDs = $this->getAttachmentObjectIDs(); + if ($attachmentObjectIDs !== []) { + $attachmentList = new GroupedAttachmentList('com.woltlab.wcf.conversation.message'); + $attachmentList->getConditionBuilder() + ->add('attachment.objectID IN (?)', [$attachmentObjectIDs]); + $attachmentList->readObjects(); + } else { + $this->attachments = null; + } + } + + return $this->attachments; + } + + /** + * @return list + * @since 6.2 + */ + protected function getAttachmentObjectIDs(): array + { + $objectIDs = []; + foreach ($this->getObjects() as $message) { + if ($message->attachments) { + $objectIDs[] = $message->getObjectID(); + } + } + + return $objectIDs; + } } diff --git a/files/lib/data/conversation/message/SearchResultConversationMessage.class.php b/files/lib/data/conversation/message/SearchResultConversationMessage.class.php index 58494c43..2c24f340 100644 --- a/files/lib/data/conversation/message/SearchResultConversationMessage.class.php +++ b/files/lib/data/conversation/message/SearchResultConversationMessage.class.php @@ -3,20 +3,22 @@ namespace wcf\data\conversation\message; use wcf\data\conversation\Conversation; +use wcf\data\DatabaseObjectDecorator; use wcf\data\search\ISearchResultObject; use wcf\system\request\LinkHandler; use wcf\system\search\SearchResultTextParser; /** - * Represents a list of search result. + * Represents a conversation message as a search result. * * @author Marcel Werk * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License * * @property-read ?string $subject + * @extends DatabaseObjectDecorator */ -class SearchResultConversationMessage extends ViewableConversationMessage implements ISearchResultObject +class SearchResultConversationMessage extends DatabaseObjectDecorator implements ISearchResultObject { /** * conversation object @@ -106,4 +108,10 @@ public function getContainerLink() { return ''; } + + #[\Override] + public function getUserProfile() + { + return $this->getDecoratedObject()->getUserProfile(); + } } diff --git a/files/lib/data/conversation/message/SearchResultConversationMessageList.class.php b/files/lib/data/conversation/message/SearchResultConversationMessageList.class.php index 3b915798..8edb29bc 100644 --- a/files/lib/data/conversation/message/SearchResultConversationMessageList.class.php +++ b/files/lib/data/conversation/message/SearchResultConversationMessageList.class.php @@ -9,18 +9,15 @@ * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License * - * @extends SimplifiedViewableConversationMessageList + * @extends ConversationMessageList */ -class SearchResultConversationMessageList extends SimplifiedViewableConversationMessageList +class SearchResultConversationMessageList extends ConversationMessageList { /** * @inheritDoc */ public $decoratorClassName = SearchResultConversationMessage::class; - /** - * Creates a new SearchResultConversationMessageList object. - */ public function __construct() { parent::__construct(); diff --git a/files/lib/data/conversation/message/SimplifiedViewableConversationMessageList.class.php b/files/lib/data/conversation/message/SimplifiedViewableConversationMessageList.class.php index 06350305..178785a6 100644 --- a/files/lib/data/conversation/message/SimplifiedViewableConversationMessageList.class.php +++ b/files/lib/data/conversation/message/SimplifiedViewableConversationMessageList.class.php @@ -9,6 +9,7 @@ * @author Marcel Werk * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License + * @deprecated 6.2 No longer in use. * * @template TDatabaseObject of ViewableConversationMessage = ViewableConversationMessage * @extends ViewableConversationMessageList diff --git a/files/lib/data/conversation/message/ViewableConversationMessage.class.php b/files/lib/data/conversation/message/ViewableConversationMessage.class.php index 5db50c28..5a018072 100644 --- a/files/lib/data/conversation/message/ViewableConversationMessage.class.php +++ b/files/lib/data/conversation/message/ViewableConversationMessage.class.php @@ -12,6 +12,7 @@ * @author Marcel Werk * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License + * @deprecated 6.2 No longer in use. * * @mixin ConversationMessage * @extends DatabaseObjectDecorator diff --git a/files/lib/data/conversation/message/ViewableConversationMessageList.class.php b/files/lib/data/conversation/message/ViewableConversationMessageList.class.php index d690e870..de70a3ac 100644 --- a/files/lib/data/conversation/message/ViewableConversationMessageList.class.php +++ b/files/lib/data/conversation/message/ViewableConversationMessageList.class.php @@ -14,6 +14,7 @@ * @author Marcel Werk * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License + * @deprecated 6.2 No longer in use. * * @template TDatabaseObject of ViewableConversationMessage = ViewableConversationMessage * @extends ConversationMessageList @@ -88,9 +89,6 @@ public function readObjects() if ($message->time > $this->maxPostTime) { $this->maxPostTime = $message->time; } - if ($this->conversation !== null) { - $message->setConversation($this->conversation); - } if ($message->attachments) { $this->attachmentObjectIDs[] = $message->messageID; diff --git a/files/lib/data/conversation/participant/ConversationParticipant.class.php b/files/lib/data/conversation/participant/ConversationParticipant.class.php new file mode 100644 index 00000000..b531f67a --- /dev/null +++ b/files/lib/data/conversation/participant/ConversationParticipant.class.php @@ -0,0 +1,53 @@ + + * @since 6.2 + * + * @property-read int $conversationParticipantID + * @property-read int $participantID + * @property-read ?int $participantID + * @property-read string $username + * @property-read bool $hideConversation + * @property-read bool $isInvisible + * @property-read int $lastVisitTime + * @property-read int $joinedAt + * @property-read int $leftAt + * @property-read ?int $lastMessageID + * @property-read bool $leftByOwnChoice + */ +class ConversationParticipant extends DatabaseObject +{ + /** + * @inheritDoc + */ + protected static $databaseTableName = 'conversation_to_user'; + + /** + * @inheritDoc + */ + protected static $databaseTableIndexName = 'conversationParticipantID'; + + public static function getParticipant(int $conversationID, int $userID): ?static + { + $sql = "SELECT * FROM wcf1_conversation_to_user WHERE conversationID = ? AND userID = ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([$conversationID, $userID]); + + $row = $statement->fetchSingleRow(); + if ($row !== false) { + return new static(null, $row); + } + + return null; + } +} diff --git a/files/lib/data/conversation/participant/ConversationParticipantAction.class.php b/files/lib/data/conversation/participant/ConversationParticipantAction.class.php new file mode 100644 index 00000000..6bc6acd3 --- /dev/null +++ b/files/lib/data/conversation/participant/ConversationParticipantAction.class.php @@ -0,0 +1,17 @@ + + * @since 6.2 + * + * @extends AbstractDatabaseObjectAction + */ +class ConversationParticipantAction extends AbstractDatabaseObjectAction {} diff --git a/files/lib/data/conversation/participant/ConversationParticipantEditor.class.php b/files/lib/data/conversation/participant/ConversationParticipantEditor.class.php new file mode 100644 index 00000000..1bdc8c4a --- /dev/null +++ b/files/lib/data/conversation/participant/ConversationParticipantEditor.class.php @@ -0,0 +1,24 @@ + + * @since 6.2 + * + * @mixin ConversationParticipant + * @extends DatabaseObjectEditor + */ +class ConversationParticipantEditor extends DatabaseObjectEditor +{ + /** + * @inheritDoc + */ + protected static $baseClass = ConversationParticipant::class; +} diff --git a/files/lib/data/conversation/participant/ConversationParticipantList.class.php b/files/lib/data/conversation/participant/ConversationParticipantList.class.php new file mode 100644 index 00000000..54513d6f --- /dev/null +++ b/files/lib/data/conversation/participant/ConversationParticipantList.class.php @@ -0,0 +1,17 @@ + + * @since 6.2 + * + * @extends DatabaseObjectList + */ +class ConversationParticipantList extends DatabaseObjectList {} diff --git a/files/lib/page/ConversationListPage.class.php b/files/lib/page/ConversationListPage.class.php index 6ce0b39a..38f47628 100644 --- a/files/lib/page/ConversationListPage.class.php +++ b/files/lib/page/ConversationListPage.class.php @@ -18,6 +18,21 @@ */ final class ConversationListPage extends AbstractListViewPage { + /** + * @inheritDoc + */ + public $loginRequired = true; + + /** + * @inheritDoc + */ + public $neededModules = ['MODULE_CONVERSATION']; + + /** + * @inheritDoc + */ + public $neededPermissions = ['user.conversation.canUseConversation']; + public string $filter = ''; public int $conversationCount = 0; diff --git a/files/lib/page/ConversationPage.class.php b/files/lib/page/ConversationPage.class.php index d84a3f6f..adb0fa61 100644 --- a/files/lib/page/ConversationPage.class.php +++ b/files/lib/page/ConversationPage.class.php @@ -5,16 +5,15 @@ use wcf\data\conversation\Conversation; use wcf\data\conversation\ConversationAction; use wcf\data\conversation\ConversationParticipantList; -use wcf\data\conversation\label\ConversationLabel; use wcf\data\conversation\label\ConversationLabelList; use wcf\data\conversation\message\ConversationMessage; -use wcf\data\conversation\message\ViewableConversationMessageList; -use wcf\data\conversation\ViewableConversation; +use wcf\data\conversation\message\ConversationMessageList; use wcf\data\modification\log\ConversationLogModificationLogList; use wcf\data\smiley\SmileyCache; use wcf\data\user\UserProfile; use wcf\system\attachment\AttachmentHandler; use wcf\system\bbcode\BBCodeHandler; +use wcf\system\cache\runtime\ConversationRuntimeCache; use wcf\system\exception\IllegalLinkException; use wcf\system\exception\PermissionDeniedException; use wcf\system\interaction\StandaloneInteractionContextMenuComponent; @@ -34,7 +33,7 @@ * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License * - * @extends MultipleLinkPage + * @extends MultipleLinkPage */ class ConversationPage extends MultipleLinkPage { @@ -51,7 +50,7 @@ class ConversationPage extends MultipleLinkPage /** * @inheritDoc */ - public $objectListClassName = ViewableConversationMessageList::class; + public $objectListClassName = ConversationMessageList::class; /** * @inheritDoc @@ -76,7 +75,7 @@ class ConversationPage extends MultipleLinkPage /** * viewable conversation object - * @var ViewableConversation + * @var Conversation */ public $conversation; @@ -132,16 +131,14 @@ public function readParameters() $this->conversationID = $this->message->conversationID; } - $conversation = Conversation::getUserConversation($this->conversationID, WCF::getUser()->userID); - if ($conversation === null) { + $this->conversation = ConversationRuntimeCache::getInstance()->getObject($this->conversationID); + if ($this->conversation === null) { throw new IllegalLinkException(); } - if (!$conversation->canRead()) { + if (!$this->conversation->canRead()) { throw new PermissionDeniedException(); } - $this->conversation = ViewableConversation::getViewableConversation($conversation); - // messages per page /** @noinspection PhpUndefinedFieldInspection */ if (WCF::getUser()->conversationMessagesPerPage) { @@ -163,7 +160,6 @@ protected function initObjectList() $this->objectList->getConditionBuilder() ->add('conversation_message.conversationID = ?', [$this->conversation->conversationID]); - $this->objectList->setConversation($this->conversation->getDecoratedObject()); // handle visibility filter if ($this->conversation->joinedAt > 0) { @@ -212,16 +208,16 @@ public function readData() if ( $this->conversation->isNew() && ( - $this->objectList->getMaxPostTime() > $this->conversation->lastVisitTime + $this->objectList->getMaxTime() > $this->conversation->lastVisitTime || ($this->conversation->joinedAt && !\count($this->objectList)) ) ) { - $visitTime = $this->objectList->getMaxPostTime(); + $visitTime = $this->objectList->getMaxTime(); if ($visitTime == $this->conversation->lastPostTime) { $visitTime = TIME_NOW; } $conversationAction = new ConversationAction( - [$this->conversation->getDecoratedObject()], + [$this->conversation], 'markAsRead', ['visitTime' => $visitTime] ); @@ -265,8 +261,8 @@ public function readData() } // set attachment permissions - if ($this->objectList->getAttachmentList() !== null) { - $this->objectList->getAttachmentList()->setPermissions([ + if ($this->objectList->getAttachments() !== null) { + $this->objectList->getAttachments()->setPermissions([ 'canDownload' => true, 'canViewPreview' => true, ]); @@ -348,7 +344,7 @@ public function assignVariables() 'attachmentObjectType' => 'com.woltlab.wcf.conversation.message', 'attachmentParentObjectID' => 0, 'tmpHash' => $tmpHash, - 'attachmentList' => $this->objectList->getAttachmentList(), + 'attachmentList' => $this->objectList->getAttachments(), 'modificationLogList' => $this->modificationLogList, 'sortOrder' => $this->sortOrder, 'conversation' => $this->conversation, diff --git a/files/lib/system/attachment/ConversationMessageAttachmentObjectType.class.php b/files/lib/system/attachment/ConversationMessageAttachmentObjectType.class.php index f4cdfc60..06e4a188 100644 --- a/files/lib/system/attachment/ConversationMessageAttachmentObjectType.class.php +++ b/files/lib/system/attachment/ConversationMessageAttachmentObjectType.class.php @@ -2,9 +2,9 @@ namespace wcf\system\attachment; -use wcf\data\conversation\Conversation; use wcf\data\conversation\message\ConversationMessage; use wcf\data\conversation\message\ConversationMessageList; +use wcf\system\cache\runtime\ConversationRuntimeCache; use wcf\system\WCF; use wcf\util\ArrayUtil; @@ -53,7 +53,7 @@ public function canDownload($objectID) { if ($objectID) { $message = new ConversationMessage($objectID); - $conversation = Conversation::getUserConversation($message->conversationID, WCF::getUser()->userID); + $conversation = ConversationRuntimeCache::getInstance()->getObject($message->conversationID); if ($conversation !== null && $conversation->canRead()) { return true; } @@ -106,18 +106,6 @@ public function cacheObjects(array $objectIDs) $messageList = new ConversationMessageList(); $messageList->setObjectIDs($objectIDs); $messageList->readObjects(); - $conversationIDs = []; - foreach ($messageList as $message) { - $conversationIDs[] = $message->conversationID; - } - if (!empty($conversationIDs)) { - $conversations = Conversation::getUserConversations($conversationIDs, WCF::getUser()->userID); - foreach ($messageList as $message) { - if (isset($conversations[$message->conversationID])) { - $message->setConversation($conversations[$message->conversationID]); - } - } - } foreach ($messageList->getObjects() as $objectID => $object) { $this->cachedObjects[$objectID] = $object; diff --git a/files/lib/system/cache/runtime/UserConversationRuntimeCache.class.php b/files/lib/system/cache/runtime/UserConversationRuntimeCache.class.php index a99619aa..2f4ec93a 100644 --- a/files/lib/system/cache/runtime/UserConversationRuntimeCache.class.php +++ b/files/lib/system/cache/runtime/UserConversationRuntimeCache.class.php @@ -2,8 +2,8 @@ namespace wcf\system\cache\runtime; +use wcf\data\conversation\Conversation; use wcf\data\conversation\UserConversationList; -use wcf\data\conversation\ViewableConversation; use wcf\system\WCF; /** @@ -13,8 +13,9 @@ * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License * @since 3.0 + * @deprecated 6.2 Use `ConversationRuntimeCache` instead. * - * @extends AbstractRuntimeCache + * @extends AbstractRuntimeCache */ class UserConversationRuntimeCache extends AbstractRuntimeCache { diff --git a/files/lib/system/conversation/TConversationForm.class.php b/files/lib/system/conversation/TConversationForm.class.php index 20a7d61a..493b8b78 100644 --- a/files/lib/system/conversation/TConversationForm.class.php +++ b/files/lib/system/conversation/TConversationForm.class.php @@ -2,9 +2,8 @@ namespace wcf\system\conversation; -use wcf\data\conversation\Conversation; +use wcf\data\user\ignore\UserIgnore; use wcf\system\database\util\PreparedStatementConditionBuilder; -use wcf\system\exception\UserInputException; use wcf\system\form\builder\field\BooleanFormField; use wcf\system\form\builder\field\MultipleSelectionFormField; use wcf\system\form\builder\field\user\UserFormField; @@ -64,22 +63,96 @@ protected function getParticipantsValidator(): FormFieldValidator UserStorageHandler::getInstance()->loadStorage($userIDs); foreach ($users as $user) { - try { - if ($user->userID === WCF::getUser()->userID) { - throw new UserInputException('isAuthor'); - } + if ($user->userID === WCF::getUser()->userID) { + $formField->addValidationError( + new FormFieldValidationError( + 'isAuthor', + 'wcf.conversation.participants.error.isAuthor' + ) + ); - Conversation::validateParticipant($user, $formField->getId()); - } catch (UserInputException $e) { + continue; + } + + // check participant's settings and permissions + if (!$user->getPermission('user.conversation.canUseConversation')) { $formField->addValidationError( new FormFieldValidationError( - $e->getType(), - 'wcf.conversation.participants.error.' . $e->getType(), + 'canNotUseConversation', + 'wcf.conversation.participants.error.canNotUseConversation', [ 'username' => $user->username, ] ) ); + + continue; + } + + if (!WCF::getSession()->getPermission('user.profile.cannotBeIgnored')) { + // check if user wants to receive any conversations + /** @noinspection PhpUndefinedFieldInspection */ + if ($user->canSendConversation == 2) { + $formField->addValidationError( + new FormFieldValidationError( + 'doesNotAcceptConversation', + 'wcf.conversation.participants.error.doesNotAcceptConversation', + [ + 'username' => $user->username, + ] + ) + ); + + continue; + } + + // check if user only wants to receive conversations by + // users they are following and if the active user is followed + // by the relevant user + /** @noinspection PhpUndefinedFieldInspection */ + if ($user->canSendConversation == 1 && !$user->isFollowing(WCF::getUser()->userID)) { + $formField->addValidationError( + new FormFieldValidationError( + 'doesNotAcceptConversation', + 'wcf.conversation.participants.error.doesNotAcceptConversation', + [ + 'username' => $user->username, + ] + ) + ); + + continue; + } + + // active user is ignored by participant + if ($user->isIgnoredUser(WCF::getUser()->userID, UserIgnore::TYPE_BLOCK_DIRECT_CONTACT)) { + $formField->addValidationError( + new FormFieldValidationError( + 'ignoresYou', + 'wcf.conversation.participants.error.ignoresYou', + [ + 'username' => $user->username, + ] + ) + ); + + continue; + } + + // check participant's mailbox quota + if (ConversationHandler::getInstance()->getConversationCount($user->userID) >= $user->getPermission('user.conversation.maxConversations')) { + $formField->addValidationError( + new FormFieldValidationError( + 'mailboxIsFull', + 'wcf.conversation.participants.error.mailboxIsFull', + [ + 'username' => $user->username, + ] + ) + ); + + continue; + } } } }); diff --git a/files/lib/system/conversation/command/AddParticipantConversation.class.php b/files/lib/system/conversation/command/AddParticipantConversation.class.php index f36f9210..aa665fb9 100644 --- a/files/lib/system/conversation/command/AddParticipantConversation.class.php +++ b/files/lib/system/conversation/command/AddParticipantConversation.class.php @@ -27,8 +27,7 @@ public function __construct( * @var 'new'|'all' */ public readonly ?string $messageVisibility - ) { - } + ) {} public function __invoke(): void { @@ -50,9 +49,5 @@ public function __invoke(): void (new ConversationAction([$this->conversation], 'update', $data))->executeAction(); ConversationModificationLogHandler::getInstance()->addParticipants($this->conversation, $this->participants); - - if (!$this->conversation->isDraft) { - (new ConversationEditor($this->conversation))->updateParticipantSummary(); - } } } diff --git a/files/lib/system/conversation/command/DeleteEmptyConversations.class.php b/files/lib/system/conversation/command/DeleteEmptyConversations.class.php index 8c18db1c..6c4909d3 100644 --- a/files/lib/system/conversation/command/DeleteEmptyConversations.class.php +++ b/files/lib/system/conversation/command/DeleteEmptyConversations.class.php @@ -23,14 +23,12 @@ final class DeleteEmptyConversations */ public function __construct( public readonly array $conversationIDs, - ) { - } + ) {} public function __invoke(): void { // update participants count and participant summary ConversationEditor::updateParticipantCounts($this->conversationIDs); - ConversationEditor::updateParticipantSummaries($this->conversationIDs); $conditionBuilder = new PreparedStatementConditionBuilder(); $conditionBuilder->add('conversation.conversationID IN (?)', [$this->conversationIDs]); diff --git a/files/lib/system/conversation/command/LeaveConversation.class.php b/files/lib/system/conversation/command/LeaveConversation.class.php index 385a6a9b..0b4d62fb 100644 --- a/files/lib/system/conversation/command/LeaveConversation.class.php +++ b/files/lib/system/conversation/command/LeaveConversation.class.php @@ -4,7 +4,6 @@ use wcf\data\conversation\Conversation; use wcf\data\conversation\ConversationList; -use wcf\system\clipboard\ClipboardHandler; use wcf\system\log\modification\ConversationModificationLogHandler; use wcf\system\user\storage\UserStorageHandler; use wcf\system\WCF; @@ -60,7 +59,6 @@ public function __invoke(): void UserStorageHandler::getInstance()->reset([WCF::getUser()->userID], 'conversationCount'); UserStorageHandler::getInstance()->reset([WCF::getUser()->userID], 'unreadConversationCount'); - // add modification log entry $conversationList = new ConversationList(); $conversationList->setObjectIDs($this->conversationIDs); @@ -71,17 +69,6 @@ public function __invoke(): void } } - // unmark items - $this->unmarkItems(); - (new DeleteEmptyConversations($this->conversationIDs))(); } - - private function unmarkItems(): void - { - ClipboardHandler::getInstance()->unmark( - $this->conversationIDs, - ClipboardHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.conversation.conversation') - ); - } } diff --git a/files/lib/system/conversation/command/RemoveConversationParticipant.class.php b/files/lib/system/conversation/command/RemoveConversationParticipant.class.php index a3336f2f..579b96cc 100644 --- a/files/lib/system/conversation/command/RemoveConversationParticipant.class.php +++ b/files/lib/system/conversation/command/RemoveConversationParticipant.class.php @@ -20,21 +20,15 @@ final class RemoveConversationParticipant public function __construct( public readonly Conversation $conversation, public readonly int $participantID, - ) { - } + ) {} public function __invoke(): void { $editor = new ConversationEditor($this->conversation); $editor->removeParticipant($this->participantID); - $editor->updateParticipantSummary(); - - $userConversation = Conversation::getUserConversation( - $this->conversation->conversationID, - $this->participantID - ); - if (!$userConversation->isInvisible) { + $participant = $this->conversation->getOtherParticipant($this->participantID); + if ($participant !== null && !$participant->isInvisible) { ConversationModificationLogHandler::getInstance()->removeParticipant($this->conversation, $this->participantID); } diff --git a/files/lib/system/endpoint/controller/core/conversations/GetConversationHeaderTitle.class.php b/files/lib/system/endpoint/controller/core/conversations/GetConversationHeaderTitle.class.php index 7bad60bd..f9d3b60e 100644 --- a/files/lib/system/endpoint/controller/core/conversations/GetConversationHeaderTitle.class.php +++ b/files/lib/system/endpoint/controller/core/conversations/GetConversationHeaderTitle.class.php @@ -5,8 +5,8 @@ use Laminas\Diactoros\Response\JsonResponse; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use wcf\data\conversation\ViewableConversation; -use wcf\system\cache\runtime\UserConversationRuntimeCache; +use wcf\data\conversation\Conversation; +use wcf\system\cache\runtime\ConversationRuntimeCache; use wcf\system\endpoint\GetRequest; use wcf\system\endpoint\IController; use wcf\system\exception\IllegalLinkException; @@ -27,7 +27,7 @@ final class GetConversationHeaderTitle implements IController #[\Override] public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface { - $conversation = UserConversationRuntimeCache::getInstance()->getObject(\intval($variables['id'])); + $conversation = ConversationRuntimeCache::getInstance()->getObject(\intval($variables['id'])); if ($conversation === null) { throw new IllegalLinkException(); } @@ -41,7 +41,7 @@ public function __invoke(ServerRequestInterface $request, array $variables): Res ]); } - private function assertConversationIsAccessible(ViewableConversation $conversation): void + private function assertConversationIsAccessible(Conversation $conversation): void { if (!$conversation->isActiveParticipant()) { throw new PermissionDeniedException(); diff --git a/files/lib/system/endpoint/controller/core/conversations/GetConversationPopover.class.php b/files/lib/system/endpoint/controller/core/conversations/GetConversationPopover.class.php index 0aa288cd..962a84c0 100644 --- a/files/lib/system/endpoint/controller/core/conversations/GetConversationPopover.class.php +++ b/files/lib/system/endpoint/controller/core/conversations/GetConversationPopover.class.php @@ -6,7 +6,7 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use wcf\data\conversation\Conversation; -use wcf\data\conversation\message\SimplifiedViewableConversationMessageList; +use wcf\data\conversation\message\ConversationMessageList; use wcf\http\Helper; use wcf\system\endpoint\GetRequest; use wcf\system\endpoint\IController; @@ -45,7 +45,7 @@ private function assertConversationIsAccessible(Conversation $conversation): voi private function renderPopover(Conversation $conversation): string { - $messageList = new SimplifiedViewableConversationMessageList(); + $messageList = new ConversationMessageList(); $messageList->getConditionBuilder() ->add("conversation_message.messageID = ?", [$conversation->firstMessageID]); $messageList->readObjects(); diff --git a/files/lib/system/interaction/bulk/user/ConversationBulkInteractions.class.php b/files/lib/system/interaction/bulk/user/ConversationBulkInteractions.class.php index b61633ba..45aebe81 100644 --- a/files/lib/system/interaction/bulk/user/ConversationBulkInteractions.class.php +++ b/files/lib/system/interaction/bulk/user/ConversationBulkInteractions.class.php @@ -3,9 +3,9 @@ namespace wcf\system\interaction\bulk\user; use wcf\action\AssignConversationLabelDialogAction; +use wcf\data\conversation\Conversation; use wcf\data\conversation\label\ConversationLabel; use wcf\data\conversation\UserConversationList; -use wcf\data\conversation\ViewableConversation; use wcf\event\interaction\bulk\user\ConversationBulkInteractionCollecting; use wcf\system\event\EventHandler; use wcf\system\interaction\bulk\AbstractBulkInteractionProvider; @@ -31,13 +31,13 @@ public function __construct() 'open', 'core/conversations/%s/open', 'wcf.conversation.edit.open', - isAvailableCallback: static fn(ViewableConversation $conversation) => $conversation->isClosed && $conversation->userID === WCF::getUser()->userID + isAvailableCallback: static fn(Conversation $conversation) => $conversation->isClosed && $conversation->userID === WCF::getUser()->userID ), new BulkRpcInteraction( 'close', 'core/conversations/%s/close', 'wcf.conversation.edit.close', - isAvailableCallback: static fn(ViewableConversation $conversation) => !$conversation->isClosed && $conversation->userID === WCF::getUser()->userID + isAvailableCallback: static fn(Conversation $conversation) => !$conversation->isClosed && $conversation->userID === WCF::getUser()->userID ), new BulkFormBuilderDialogInteraction( 'assignLabel', @@ -51,7 +51,7 @@ public function __construct() 'wcf.conversation.hideConversation.restore', InteractionConfirmationType::Custom, 'wcf.conversation.hideConversation.restore.confirmationMessage', - static fn(ViewableConversation $conversation) => (bool)$conversation->hideConversation + static fn(Conversation $conversation) => (bool)$conversation->hideConversation ), new BulkRpcInteraction( 'leave', @@ -59,7 +59,7 @@ public function __construct() 'wcf.conversation.hideConversation.leave', InteractionConfirmationType::Custom, 'wcf.conversation.hideConversation.leave.confirmationMessage', - static fn(ViewableConversation $conversation) => !$conversation->hideConversation + static fn(Conversation $conversation) => !$conversation->hideConversation ), new BulkRpcInteraction( 'leave-permanently', diff --git a/files/lib/system/interaction/user/ConversationInteractions.class.php b/files/lib/system/interaction/user/ConversationInteractions.class.php index 87447066..a9d6c946 100644 --- a/files/lib/system/interaction/user/ConversationInteractions.class.php +++ b/files/lib/system/interaction/user/ConversationInteractions.class.php @@ -7,10 +7,8 @@ use wcf\action\EditSubjectConversationDialogAction; use wcf\data\conversation\Conversation; use wcf\data\conversation\label\ConversationLabel; -use wcf\data\conversation\ViewableConversation; use wcf\event\interaction\user\ConversationInteractionCollecting; use wcf\form\ConversationDraftEditForm; -use wcf\system\cache\runtime\UserConversationRuntimeCache; use wcf\system\event\EventHandler; use wcf\system\interaction\AbstractInteractionProvider; use wcf\system\interaction\Divider; @@ -39,19 +37,19 @@ public function __construct() 'editSubject', LinkHandler::getInstance()->getControllerLink(EditSubjectConversationDialogAction::class, ['id' => '%s']), 'wcf.conversation.edit.subject', - static fn(ViewableConversation|Conversation $conversation) => WCF::getUser()->userID === $conversation->userID, + static fn(Conversation $conversation) => WCF::getUser()->userID === $conversation->userID, ), new RpcInteraction( 'open', 'core/conversations/%s/open', 'wcf.conversation.edit.open', - isAvailableCallback: static fn(ViewableConversation|Conversation $conversation) => $conversation->isClosed && $conversation->userID === WCF::getUser()->userID + isAvailableCallback: static fn(Conversation $conversation) => $conversation->isClosed && $conversation->userID === WCF::getUser()->userID ), new RpcInteraction( 'close', 'core/conversations/%s/close', 'wcf.conversation.edit.close', - isAvailableCallback: static fn(ViewableConversation|Conversation $conversation) => !$conversation->isClosed && $conversation->userID === WCF::getUser()->userID + isAvailableCallback: static fn(Conversation $conversation) => !$conversation->isClosed && $conversation->userID === WCF::getUser()->userID ), new FormBuilderDialogInteraction( 'assignLabel', @@ -64,7 +62,7 @@ public function __construct() 'addParticipants', LinkHandler::getInstance()->getControllerLink(AddParticipantConversationDialogAction::class, ['id' => '%s']), 'wcf.conversation.edit.addParticipants', - static fn(ViewableConversation|Conversation $conversation) => $conversation->canAddParticipants(), + static fn(Conversation $conversation) => $conversation->canAddParticipants(), ), new RpcInteraction( 'restore', @@ -72,11 +70,7 @@ public function __construct() 'wcf.conversation.hideConversation.restore', InteractionConfirmationType::Custom, 'wcf.conversation.hideConversation.restore.confirmationMessage', - static function (ViewableConversation|Conversation $conversation) { - if (!($conversation instanceof ViewableConversation)) { - $conversation = UserConversationRuntimeCache::getInstance()->getObject($conversation->conversationID); - } - + static function (Conversation $conversation) { return (bool)$conversation->hideConversation; }, ), @@ -86,11 +80,7 @@ static function (ViewableConversation|Conversation $conversation) { 'wcf.conversation.hideConversation.leave', InteractionConfirmationType::Custom, 'wcf.conversation.hideConversation.leave.confirmationMessage', - static function (ViewableConversation|Conversation $conversation) { - if (!($conversation instanceof ViewableConversation)) { - $conversation = UserConversationRuntimeCache::getInstance()->getObject($conversation->conversationID); - } - + static function (Conversation $conversation) { return !$conversation->hideConversation; }, ), @@ -104,7 +94,7 @@ static function (ViewableConversation|Conversation $conversation) { ), new EditInteraction( ConversationDraftEditForm::class, - static function (ViewableConversation|Conversation $conversation) { + static function (Conversation $conversation) { return $conversation->isDraft; } ), diff --git a/files/lib/system/listView/user/ConversationListView.class.php b/files/lib/system/listView/user/ConversationListView.class.php index 5a17b2b3..2505aa64 100644 --- a/files/lib/system/listView/user/ConversationListView.class.php +++ b/files/lib/system/listView/user/ConversationListView.class.php @@ -2,9 +2,9 @@ namespace wcf\system\listView\user; +use wcf\data\conversation\Conversation; use wcf\data\conversation\label\ConversationLabel; use wcf\data\conversation\UserConversationList; -use wcf\data\conversation\ViewableConversation; use wcf\data\DatabaseObjectList; use wcf\event\listView\user\ConversationListViewInitialized; use wcf\system\form\builder\field\AbstractFormField; @@ -24,7 +24,7 @@ * @license GNU Lesser General Public License * @since 6.2 * - * @extends AbstractListView + * @extends AbstractListView */ final class ConversationListView extends AbstractListView { @@ -63,7 +63,8 @@ public function __construct(string $filter = '') $this->setItemsPerPage(WCF::getUser()->conversationsPerPage ?: \CONVERSATIONS_PER_PAGE); $this->setSortField(\CONVERSATION_LIST_DEFAULT_SORT_FIELD); $this->setSortOrder(\CONVERSATION_LIST_DEFAULT_SORT_ORDER); - $this->setCssClassName("tabularList"); + $this->setCssClassName("discussionList conversationList"); + $this->setContainerCssClassName('discussionList__container conversationList__container'); } #[\Override] diff --git a/files/lib/system/moderation/queue/report/ConversationMessageModerationQueueReportHandler.class.php b/files/lib/system/moderation/queue/report/ConversationMessageModerationQueueReportHandler.class.php index 375260c0..12c55d57 100644 --- a/files/lib/system/moderation/queue/report/ConversationMessageModerationQueueReportHandler.class.php +++ b/files/lib/system/moderation/queue/report/ConversationMessageModerationQueueReportHandler.class.php @@ -3,11 +3,9 @@ namespace wcf\system\moderation\queue\report; use wcf\data\conversation\Conversation; -use wcf\data\conversation\ConversationList; use wcf\data\conversation\message\ConversationMessage; use wcf\data\conversation\message\ConversationMessageAction; use wcf\data\conversation\message\ConversationMessageList; -use wcf\data\conversation\message\ViewableConversationMessage; use wcf\data\moderation\queue\ModerationQueue; use wcf\data\moderation\queue\ViewableModerationQueue; use wcf\system\moderation\queue\AbstractModerationQueueHandler; @@ -98,7 +96,7 @@ public function getContainerID($objectID) public function getReportedContent(ViewableModerationQueue $queue) { return WCF::getTPL()->render('wcf', 'moderationConversationMessage', [ - 'message' => ViewableConversationMessage::getViewableConversationMessage($queue->objectID), + 'message' => new Conversation($queue->objectID), ]); } @@ -166,25 +164,9 @@ public function populate(array $queues) } } - // fetch conversations - $conversationIDs = []; - foreach ($messages as $message) { - $conversationIDs[] = $message->conversationID; - } - - if (!empty($conversationIDs)) { - $conversationList = new ConversationList(); - $conversationList->setObjectIDs($conversationIDs); - $conversationList->readObjects(); - $conversations = $conversationList->getObjects(); - - foreach ($queues as $object) { - if (isset($messages[$object->objectID])) { - $message = $messages[$object->objectID]; - $message->setConversation($conversations[$message->conversationID]); - - $object->setAffectedObject($message); - } + foreach ($queues as $object) { + if (isset($messages[$object->objectID])) { + $object->setAffectedObject($messages[$object->objectID]); } } } diff --git a/files/lib/system/page/handler/TConversationOnlineLocationPageHandler.class.php b/files/lib/system/page/handler/TConversationOnlineLocationPageHandler.class.php index 2b8cc34c..9d53d9a9 100644 --- a/files/lib/system/page/handler/TConversationOnlineLocationPageHandler.class.php +++ b/files/lib/system/page/handler/TConversationOnlineLocationPageHandler.class.php @@ -2,10 +2,9 @@ namespace wcf\system\page\handler; -use wcf\data\conversation\Conversation; use wcf\data\page\Page; use wcf\data\user\online\UserOnline; -use wcf\system\cache\runtime\UserConversationRuntimeCache; +use wcf\system\cache\runtime\ConversationRuntimeCache; use wcf\system\WCF; /** @@ -27,7 +26,6 @@ trait TConversationOnlineLocationPageHandler * @param UserOnline $user user online object with request data * @return string * @see IOnlineLocationPageHandler::getOnlineLocation() - * */ public function getOnlineLocation(Page $page, UserOnline $user) { @@ -35,7 +33,7 @@ public function getOnlineLocation(Page $page, UserOnline $user) return ''; } - $conversation = UserConversationRuntimeCache::getInstance()->getObject($user->pageObjectID); + $conversation = ConversationRuntimeCache::getInstance()->getObject($user->pageObjectID); if ($conversation === null || !$conversation->canRead()) { return ''; } @@ -43,8 +41,8 @@ public function getOnlineLocation(Page $page, UserOnline $user) if ($conversation->userID != WCF::getUser()->userID && $user->userID != WCF::getUser()->userID) { // Make sure that requests from invisible participants are not listed // if the active user is not the author of the conversation. - $userConversation = Conversation::getUserConversation($conversation->conversationID, $user->userID); - if ($userConversation !== null && $userConversation->isInvisible) { + $participant = $conversation->getOtherParticipant($user->userID); + if ($participant !== null && $participant->isInvisible) { return ''; } } @@ -68,7 +66,7 @@ public function prepareOnlineLocation( UserOnline $user ) { if ($user->pageObjectID !== null) { - UserConversationRuntimeCache::getInstance()->cacheObjectID($user->pageObjectID); + ConversationRuntimeCache::getInstance()->cacheObjectID($user->pageObjectID); } } } diff --git a/files/lib/system/search/ConversationMessageSearch.class.php b/files/lib/system/search/ConversationMessageSearch.class.php index cab3fa87..6e76fa9b 100644 --- a/files/lib/system/search/ConversationMessageSearch.class.php +++ b/files/lib/system/search/ConversationMessageSearch.class.php @@ -6,6 +6,7 @@ use wcf\data\conversation\message\SearchResultConversationMessage; use wcf\data\conversation\message\SearchResultConversationMessageList; use wcf\data\search\ISearchResultObject; +use wcf\system\cache\runtime\ConversationRuntimeCache; use wcf\system\database\util\PreparedStatementConditionBuilder; use wcf\system\WCF; @@ -146,7 +147,7 @@ public function getFormTemplateName(): string public function assignVariables(): void { if (!empty($_REQUEST['conversationID'])) { - $conversation = Conversation::getUserConversation(\intval($_REQUEST['conversationID']), WCF::getUser()->userID); + $conversation = ConversationRuntimeCache::getInstance()->getObject(\intval($_REQUEST['conversationID'])); if ($conversation !== null && $conversation->canRead()) { $this->conversation = $conversation; WCF::getTPL()->assign('searchedConversation', $conversation); diff --git a/files/lib/system/user/notification/object/type/ConversationNotificationObjectType.class.php b/files/lib/system/user/notification/object/type/ConversationNotificationObjectType.class.php index 5c04933c..55e65bdf 100644 --- a/files/lib/system/user/notification/object/type/ConversationNotificationObjectType.class.php +++ b/files/lib/system/user/notification/object/type/ConversationNotificationObjectType.class.php @@ -4,8 +4,8 @@ use wcf\data\conversation\Conversation; use wcf\data\conversation\ConversationList; +use wcf\system\cache\runtime\ConversationRuntimeCache; use wcf\system\user\notification\object\ConversationUserNotificationObject; -use wcf\system\WCF; /** * Represents a conversation notification object type. @@ -36,8 +36,7 @@ class ConversationNotificationObjectType extends AbstractUserNotificationObjectT */ public function getObjectsByIDs(array $objectIDs) { - $objects = Conversation::getUserConversations($objectIDs, WCF::getUser()->userID); - + $objects = ConversationRuntimeCache::getInstance()->getObjects($objectIDs); foreach ($objects as $objectID => $conversation) { $objects[$objectID] = new static::$decoratorClassName($conversation); } diff --git a/files/lib/system/worker/ConversationMessageSearchIndexRebuildDataWorker.class.php b/files/lib/system/worker/ConversationMessageSearchIndexRebuildDataWorker.class.php index 8ae4d47b..0676ff89 100644 --- a/files/lib/system/worker/ConversationMessageSearchIndexRebuildDataWorker.class.php +++ b/files/lib/system/worker/ConversationMessageSearchIndexRebuildDataWorker.class.php @@ -71,20 +71,7 @@ public function execute() return; } - // read associated conversations - $conversationIDs = \array_column( - $this->getObjectList()->getObjects(), - 'conversationID' - ); - - $threadList = new ConversationList(); - $threadList->setObjectIDs($conversationIDs); - $threadList->readObjects(); - $conversations = $threadList->getObjects(); - foreach ($this->getObjectList() as $message) { - $message->setConversation($conversations[$message->conversationID]); - $subject = ''; if ($message->messageID == $message->getConversation()->firstMessageID) { $subject = $message->getTitle(); diff --git a/files/lib/system/worker/ConversationRebuildDataWorker.class.php b/files/lib/system/worker/ConversationRebuildDataWorker.class.php index 016de8be..a4dcb31d 100644 --- a/files/lib/system/worker/ConversationRebuildDataWorker.class.php +++ b/files/lib/system/worker/ConversationRebuildDataWorker.class.php @@ -170,14 +170,6 @@ public function execute() ]); $data['participants'] = $participantCounterStatement->fetchSingleColumn(); - // get participant summary - $participantStatement->execute([$conversation->conversationID, $conversation->userID, 0]); - $users = []; - while ($row = $participantStatement->fetchArray()) { - $users[] = $row; - } - $data['participantSummary'] = \serialize($users); - $updateData[$conversation->conversationID] = $data; } @@ -190,8 +182,7 @@ public function execute() username = ?, replies = ?, attachments = ?, - participants = ?, - participantSummary = ? + participants = ? WHERE conversationID = ?"; $statement = WCF::getDB()->prepare($sql); @@ -207,7 +198,6 @@ public function execute() $data['replies'], $data['attachments'], $data['participants'], - $data['participantSummary'], $conversationID, ]); } diff --git a/files/style/conversation.scss b/files/style/conversation.scss index bcf0ef87..53ae13b0 100644 --- a/files/style/conversation.scss +++ b/files/style/conversation.scss @@ -25,47 +25,25 @@ width: 100%; } -.conversationItem { - .conversationInfo { - display: flex; - } - - .conversationParticipant { - flex: 1 1 auto; - } - .conversationLastPostTime { - flex: 0 0 auto; - margin-left: 10px; - } +.conversationList__item__participants { + display: flex; + flex-direction: row-reverse; + margin-left: -10px; + margin-right: 25px; + align-items: center; } -.conversationList { - .columnInteractions { - display: flex; - flex-direction: column; +@include screen-xs { + .conversationList__item__participants { + display: none; } +} - @include screen-sm-down { - .conversationList_columnSubject { - flex-basis: calc(100% - 80px) !important; - max-width: calc(100% - 80px) !important; - } - .conversationList_columnAvatar { - margin-left: 5px; - margin-right: 5px !important; - } - } +.conversationList__item__participant { + transform: translateX(10px); + width: 16px; +} - /* revert style from `messageGroup.scss` */ - @include screen-md-up { - &.messageGroupList .pagination { - font-size: inherit; - } - } - .pagination { - flex: inherit; - opacity: inherit; - transition: inherit; - font-weight: inherit; - } +.conversationList__item__otherParticipant { + transform: translateX(20px); } diff --git a/language/de.xml b/language/de.xml index 7f2cd2ff..32731896 100644 --- a/language/de.xml +++ b/language/de.xml @@ -60,17 +60,6 @@ - - - - - - - - - - - @@ -120,21 +109,19 @@ + - {$searchedConversation->getTitle()} durchsuchen]]> - participants - $participantSummaryCount == 1}ein weiterer{else}{#$conversation->participants-$participantSummaryCount} weitere{/if}]]> - attachments == 1}einen Dateianhang{else}{#$conversation->attachments} Dateianhänge{/if}.]]> username} hat diese Nachricht {#$message->editCount} Mal editiert, zuletzt: {time time=$message->lastEditTime}.]]> - + @@ -158,7 +145,7 @@ - + diff --git a/language/en.xml b/language/en.xml index dd76892b..5783041f 100644 --- a/language/en.xml +++ b/language/en.xml @@ -109,21 +109,19 @@ + - {$searchedConversation->getTitle()}]]> - participants - $participantSummaryCount == 1}one other{else}{#$conversation->participants-$participantSummaryCount} others{/if}]]> - attachments} Attachment{if $conversation->attachments != 1}s{/if}]]> username} edited this message {#$message->editCount} times, last: {time time=$message->lastEditTime}.]]> - + @@ -147,7 +145,7 @@ - + diff --git a/templates/conversationList.tpl b/templates/conversationList.tpl index ac382c16..60a1089b 100644 --- a/templates/conversationList.tpl +++ b/templates/conversationList.tpl @@ -88,14 +88,14 @@ {include file='header'} -
+
{unsafe:$listView->render()}