From 26352dcaf1024edef85a4b26bb8289e864574926 Mon Sep 17 00:00:00 2001 From: Cyperghost Date: Wed, 25 Jun 2025 12:32:50 +0200 Subject: [PATCH 01/24] Migrate `ConversationListPage` to ListView --- ...ignConversationLabelDialogAction.class.php | 124 +++++++ .../label/ConversationLabel.class.php | 11 + ...rsationBulkInteractionCollecting.class.php | 21 ++ ...onversationInteractionCollecting.class.php | 21 ++ .../ConversationListViewInitialized.class.php | 21 ++ files/lib/page/ConversationListPage.class.php | 316 ++--------------- .../command/AssignConversationLabel.class.php | 80 +++++ .../AssignConversationLabels.class.php | 2 + .../GetConversationLabels.class.php | 2 + .../GetConversationLeaveDialog.class.php | 2 + .../conversations/LeaveConversation.class.php | 2 + .../ConversationLabelFormField.class.php | 75 ++++ .../ConversationBulkInteractions.class.php | 46 +++ .../user/ConversationInteractions.class.php | 54 +++ .../user/ConversationListView.class.php | 127 +++++++ files/style/conversation.scss | 5 + templates/conversationList.tpl | 328 +----------------- templates/conversationListItems.tpl | 121 +++++++ .../shared_conversationLabelFormField.tpl | 38 ++ 19 files changed, 794 insertions(+), 602 deletions(-) create mode 100644 files/lib/action/AssignConversationLabelDialogAction.class.php create mode 100644 files/lib/event/interaction/bulk/user/ConversationBulkInteractionCollecting.class.php create mode 100644 files/lib/event/interaction/user/ConversationInteractionCollecting.class.php create mode 100644 files/lib/event/listView/user/ConversationListViewInitialized.class.php create mode 100644 files/lib/system/conversation/command/AssignConversationLabel.class.php create mode 100644 files/lib/system/form/builder/field/ConversationLabelFormField.class.php create mode 100644 files/lib/system/interaction/bulk/user/ConversationBulkInteractions.class.php create mode 100644 files/lib/system/interaction/user/ConversationInteractions.class.php create mode 100644 files/lib/system/listView/user/ConversationListView.class.php create mode 100644 templates/conversationListItems.tpl create mode 100644 templates/shared_conversationLabelFormField.tpl diff --git a/files/lib/action/AssignConversationLabelDialogAction.class.php b/files/lib/action/AssignConversationLabelDialogAction.class.php new file mode 100644 index 00000000..8adc8e2f --- /dev/null +++ b/files/lib/action/AssignConversationLabelDialogAction.class.php @@ -0,0 +1,124 @@ + + * @since 6.2 + */ +final class AssignConversationLabelDialogAction implements RequestHandlerInterface +{ + #[\Override] + public function handle(ServerRequestInterface $request): ResponseInterface + { + $parameters = Helper::mapQueryParameters( + $request->getQueryParams(), + <<<'EOT' + array { + id?: positive-int, + objectIDs?: positive-int[] + } + EOT + ); + + if (!isset($parameters['id']) && !isset($parameters['objectIDs'])) { + throw new IllegalLinkException(); + } + + $conversationIDs = $parameters['objectIDs'] ?? [$parameters['id']]; + + if ($conversationIDs === []) { + throw new IllegalLinkException(); + } + + if (!Conversation::isParticipant($conversationIDs)) { + throw new PermissionDeniedException(); + } + + $labelList = ConversationLabel::getLabelsByUser(); + if ($labelList->count() === 0) { + throw new IllegalLinkException(); + } + + $form = $this->getForm($conversationIDs, $labelList); + + if ($request->getMethod() === 'GET') { + return $form->toResponse(); + } elseif ($request->getMethod() === 'POST') { + $response = $form->validateRequest($request); + if ($response !== null) { + return $response; + } + $labelIDs = $form->getData()['labelIDs'] ?? []; + + (new AssignConversationLabel($labelList, $conversationIDs, $labelIDs))(); + + return new JsonResponse([]); + } else { + throw new \LogicException('Unreachable'); + } + } + + /** + * @param int[] $conversationIDs + */ + private function getForm(array $conversationIDs, ConversationLabelList $labelList): Psr15DialogForm + { + $form = new Psr15DialogForm( + static::class, + WCF::getLanguage()->get('wcf.conversation.label.assignLabels') + ); + + $form->appendChildren([ + MultipleSelectionFormField::create('labelIDs') + ->options( + \array_map(static fn (ConversationLabel $label) => $label->render(), $labelList->getObjects()) + ) + ->value($this->getSelectedLabelIDs($conversationIDs)), + ]); + + $form->markRequiredFields(false); + $form->build(); + + return $form; + } + + /** + * @param int[] $conversationIDs + * + * @return int[] + */ + private function getSelectedLabelIDs(array $conversationIDs): array + { + if (\count($conversationIDs) !== 1) { + return []; + } + + $sql = "SELECT labelID + FROM wcf1_conversation_label_to_object + WHERE conversationID = ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([\reset($conversationIDs)]); + + return $statement->fetchAll(\PDO::FETCH_COLUMN) ?: []; + } +} diff --git a/files/lib/data/conversation/label/ConversationLabel.class.php b/files/lib/data/conversation/label/ConversationLabel.class.php index f81ee91c..3330d4e3 100644 --- a/files/lib/data/conversation/label/ConversationLabel.class.php +++ b/files/lib/data/conversation/label/ConversationLabel.class.php @@ -4,6 +4,7 @@ use wcf\data\DatabaseObject; use wcf\system\WCF; +use wcf\util\StringUtil; /** * Represents a conversation label. @@ -64,4 +65,14 @@ public static function getLabelCssClassNames() { return self::$availableCssClassNames; } + + public function render(): string + { + $cssClassName = $this->cssClassName ? ' ' . $this->cssClassName : ''; + $title = StringUtil::encodeHTML($this->label); + + return <<{$title} + HTML; + } } diff --git a/files/lib/event/interaction/bulk/user/ConversationBulkInteractionCollecting.class.php b/files/lib/event/interaction/bulk/user/ConversationBulkInteractionCollecting.class.php new file mode 100644 index 00000000..a22a1c39 --- /dev/null +++ b/files/lib/event/interaction/bulk/user/ConversationBulkInteractionCollecting.class.php @@ -0,0 +1,21 @@ + + * @since 6.2 + */ +final class ConversationBulkInteractionCollecting implements IPsr14Event +{ + public function __construct(public readonly ConversationBulkInteractions $param) + { + } +} diff --git a/files/lib/event/interaction/user/ConversationInteractionCollecting.class.php b/files/lib/event/interaction/user/ConversationInteractionCollecting.class.php new file mode 100644 index 00000000..9cebaf27 --- /dev/null +++ b/files/lib/event/interaction/user/ConversationInteractionCollecting.class.php @@ -0,0 +1,21 @@ + + * @since 6.2 + */ +final class ConversationInteractionCollecting implements IPsr14Event +{ + public function __construct(public readonly ConversationInteractions $param) + { + } +} diff --git a/files/lib/event/listView/user/ConversationListViewInitialized.class.php b/files/lib/event/listView/user/ConversationListViewInitialized.class.php new file mode 100644 index 00000000..8a279ee2 --- /dev/null +++ b/files/lib/event/listView/user/ConversationListViewInitialized.class.php @@ -0,0 +1,21 @@ + + * @since 6.2 + */ +final class ConversationListViewInitialized implements IPsr14Event +{ + public function __construct(public readonly ConversationListView $param) + { + } +} diff --git a/files/lib/page/ConversationListPage.class.php b/files/lib/page/ConversationListPage.class.php index 69c0ac1f..6ce0b39a 100644 --- a/files/lib/page/ConversationListPage.class.php +++ b/files/lib/page/ConversationListPage.class.php @@ -2,115 +2,33 @@ namespace wcf\page; -use wcf\data\conversation\label\ConversationLabel; -use wcf\data\conversation\label\ConversationLabelList; use wcf\data\conversation\UserConversationList; -use wcf\system\clipboard\ClipboardHandler; -use wcf\system\database\util\PreparedStatementConditionBuilder; -use wcf\system\exception\IllegalLinkException; -use wcf\system\page\PageLocationManager; +use wcf\system\listView\user\ConversationListView; use wcf\system\request\LinkHandler; use wcf\system\WCF; -use wcf\util\ArrayUtil; -use wcf\util\HeaderUtil; /** * Shows a list of conversations. * - * @author Marcel Werk - * @copyright 2001-2019 WoltLab GmbH + * @author Olaf Braun, Marcel Werk + * @copyright 2001-2025 WoltLab GmbH * @license GNU Lesser General Public License * - * @extends SortablePage + * @extends AbstractListViewPage */ -class ConversationListPage extends SortablePage +final class ConversationListPage extends AbstractListViewPage { - /** - * @inheritDoc - */ - public $defaultSortField = CONVERSATION_LIST_DEFAULT_SORT_FIELD; - - /** - * @inheritDoc - */ - public $defaultSortOrder = CONVERSATION_LIST_DEFAULT_SORT_ORDER; - - /** - * @inheritDoc - */ - public $validSortFields = ['subject', 'time', 'username', 'lastPostTime', 'replies', 'participants']; - - /** - * @inheritDoc - */ - public $itemsPerPage = CONVERSATIONS_PER_PAGE; - - /** - * @inheritDoc - */ - public $loginRequired = true; - - /** - * @inheritDoc - */ - public $neededModules = ['MODULE_CONVERSATION']; - - /** - * @inheritDoc - */ - public $neededPermissions = ['user.conversation.canUseConversation']; + public string $filter = ''; - /** - * list filter - * @var string - */ - public $filter = ''; + public int $conversationCount = 0; - /** - * label id - * @var int - */ - public $labelID = 0; + public int $draftCount = 0; - /** - * label list object - * @var ConversationLabelList - */ - public $labelList; - - /** - * number of conversations (no filter) - * @var int - */ - public $conversationCount = 0; + public int $hiddenCount = 0; - /** - * number of drafts - * @var int - */ - public $draftCount = 0; + public int $outboxCount = 0; - /** - * number of hidden conversations - * @var int - */ - public $hiddenCount = 0; - - /** - * number of sent conversations - * @var int - */ - public $outboxCount = 0; - - /** - * participant that - * @var string[] - */ - public $participants = []; - - /** - * @inheritDoc - */ + #[\Override] public function readParameters() { parent::readParameters(); @@ -121,131 +39,6 @@ public function readParameters() if (!\in_array($this->filter, UserConversationList::$availableFilters)) { $this->filter = ''; } - - // user settings - /** @noinspection PhpUndefinedFieldInspection */ - if (WCF::getUser()->conversationsPerPage) { - /** @noinspection PhpUndefinedFieldInspection */ - $this->itemsPerPage = WCF::getUser()->conversationsPerPage; - } - - // labels - $this->labelList = ConversationLabel::getLabelsByUser(); - if (!empty($_REQUEST['labelID'])) { - $this->labelID = \intval($_REQUEST['labelID']); - - $validLabel = false; - foreach ($this->labelList as $label) { - if ($label->labelID == $this->labelID) { - $validLabel = true; - break; - } - } - - if (!$validLabel) { - throw new IllegalLinkException(); - } - } - - if (isset($_REQUEST['participants'])) { - $this->participants = \array_slice(ArrayUtil::trim(\explode(',', $_REQUEST['participants'])), 0, 20); - } - - if (!empty($_POST)) { - $participantsParameter = ''; - foreach ($this->participants as $participant) { - if (!empty($participantsParameter)) { - $participantsParameter .= ','; - } - $participantsParameter .= \rawurlencode($participant); - } - if (!empty($participantsParameter)) { - $participantsParameter = '&participants=' . $participantsParameter; - } - - HeaderUtil::redirect( - LinkHandler::getInstance()->getLink( - 'ConversationList', - [], - 'sortField=' . $this->sortField . '&sortOrder=' . $this->sortOrder . '&filter=' . $this->filter . '&labelID=' . $this->labelID . '&pageNo=' . $this->pageNo . $participantsParameter - ) - ); - - exit; - } - } - - /** - * @inheritDoc - */ - protected function initObjectList() - { - $this->objectList = new UserConversationList(WCF::getUser()->userID, $this->filter, $this->labelID); - $this->objectList->setLabelList($this->labelList); - - if (!empty($this->participants)) { - // The column `conversation_to_user.username` has no index, causing full table scans when - // trying to filter by it, therefore we'll read the user ids in advance. - $conditions = new PreparedStatementConditionBuilder(); - $conditions->add('username IN (?)', [$this->participants]); - $sql = "SELECT userID - FROM wcf1_user - " . $conditions; - $statement = WCF::getDB()->prepare($sql); - $statement->execute($conditions->getParameters()); - $userIDs = []; - while ($userID = $statement->fetchColumn()) { - $userIDs[] = $userID; - } - - if (!empty($userIDs)) { - // The condition is split into two branches in order to account for invisible participants. - // Invisible participants are only visible to the conversation starter and remain invisible - // until they write their first message. - // - // We need to protect these users from being exposed as participants by including them for - // any conversation that the current user has started. For all other conversations, users - // flagged with `isInvisible = 0` must be excluded. - // - // See https://github.com/WoltLab/com.woltlab.wcf.conversation/issues/131 - $this->objectList->getConditionBuilder()->add(' - ( - ( - conversation.userID = ? - AND conversation.conversationID IN ( - SELECT conversationID - FROM wcf1_conversation_to_user - WHERE participantID IN (?) - GROUP BY conversationID - HAVING COUNT(conversationID) = ? - ) - ) - OR - ( - conversation.userID <> ? - AND conversation.conversationID IN ( - SELECT conversationID - FROM wcf1_conversation_to_user - WHERE participantID IN (?) - AND isInvisible = ? - GROUP BY conversationID - HAVING COUNT(conversationID) = ? - ) - ) - )', [ - // Parameters for the first condition. - WCF::getUser()->userID, - $userIDs, - \count($userIDs), - - // Parameters for the second condition. - WCF::getUser()->userID, - $userIDs, - 0, - \count($userIDs), - ]); - } - } } /** @@ -253,83 +46,46 @@ protected function initObjectList() */ public function readData() { - // if sort field is `username`, `conversation.` has to prepended because `username` - // alone is ambiguous - if ($this->sortField === 'username') { - $this->sortField = 'conversation.username'; - } - parent::readData(); - // change back to old value - if ($this->sortField === 'conversation.username') { - $this->sortField = 'username'; - } - - if ($this->filter != '') { - // `-1` = pseudo object id to have to pages with identifier `com.woltlab.wcf.conversation.ConversationList` - PageLocationManager::getInstance()->addParentLocation('com.woltlab.wcf.conversation.ConversationList', -1); - } - - // read stats - if (!$this->labelID && empty($this->participants)) { - switch ($this->filter) { - case '': - $this->conversationCount = $this->items; - break; - - case 'draft': - $this->draftCount = $this->items; - break; - - case 'hidden': - $this->hiddenCount = $this->items; - break; - - case 'outbox': - $this->outboxCount = $this->items; - break; - } - } + $this->conversationCount = $this->getConversationCount(''); + $this->draftCount = $this->getConversationCount('draft'); + $this->hiddenCount = $this->getConversationCount('hidden'); + $this->outboxCount = $this->getConversationCount('outbox'); + } - if ($this->filter != '' || $this->labelID || !empty($this->participants)) { - $conversationList = new UserConversationList(WCF::getUser()->userID, ''); - $this->conversationCount = $conversationList->countObjects(); - } - if ($this->filter != 'draft' || $this->labelID || !empty($this->participants)) { - $conversationList = new UserConversationList(WCF::getUser()->userID, 'draft'); - $this->draftCount = $conversationList->countObjects(); - } - if ($this->filter != 'hidden' || $this->labelID || !empty($this->participants)) { - $conversationList = new UserConversationList(WCF::getUser()->userID, 'hidden'); - $this->hiddenCount = $conversationList->countObjects(); - } - if ($this->filter != 'outbox' || $this->labelID || !empty($this->participants)) { - $conversationList = new UserConversationList(WCF::getUser()->userID, 'outbox'); - $this->outboxCount = $conversationList->countObjects(); - } + private function getConversationCount(string $filter): int + { + return (new UserConversationList(WCF::getUser()->userID, $filter))->countObjects(); } - /** - * @inheritDoc - */ + #[\Override] public function assignVariables() { parent::assignVariables(); WCF::getTPL()->assign([ 'filter' => $this->filter, - 'hasMarkedItems' => ClipboardHandler::getInstance()->hasMarkedItems( - ClipboardHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.conversation.conversation') - ), - 'labelID' => $this->labelID, - 'labelList' => $this->labelList, 'conversationCount' => $this->conversationCount, 'draftCount' => $this->draftCount, 'hiddenCount' => $this->hiddenCount, 'outboxCount' => $this->outboxCount, - 'participants' => $this->participants, - 'validSortFields' => $this->validSortFields, ]); } + + #[\Override] + protected function createListView(): ConversationListView + { + return new ConversationListView($this->filter); + } + + #[\Override] + protected function initListView(): void + { + parent::initListView(); + + $this->listView->setBaseUrl(LinkHandler::getInstance()->getControllerLink(static::class, [ + 'filter' => $this->filter, + ])); + } } diff --git a/files/lib/system/conversation/command/AssignConversationLabel.class.php b/files/lib/system/conversation/command/AssignConversationLabel.class.php new file mode 100644 index 00000000..63a08291 --- /dev/null +++ b/files/lib/system/conversation/command/AssignConversationLabel.class.php @@ -0,0 +1,80 @@ + + * @since 6.2 + */ +final class AssignConversationLabel +{ + public function __construct( + public readonly ConversationLabelList $labelList, + /** + * @var int[] + */ + public readonly array $conversationIDs, + /** + * @var int[] + */ + public readonly array $labelIDs + ) { + } + + public function __invoke(): void + { + $this->removeOldLabels($this->conversationIDs, $this->labelList->getObjectIDs()); + $this->assignLabels($this->conversationIDs, $this->labelIDs); + } + + /** + * @param int[] $conversationIDs + * @param int[] $labelIDs + */ + private function removeOldLabels(array $conversationIDs, array $labelIDs): void + { + if ($labelIDs === []) { + return; + } + + $conditions = new PreparedStatementConditionBuilder(); + $conditions->add("conversationID IN (?)", [$conversationIDs]); + $conditions->add("labelID IN (?)", [$labelIDs]); + + $sql = "DELETE FROM wcf1_conversation_label_to_object + " . $conditions; + $statement = WCF::getDB()->prepare($sql); + $statement->execute($conditions->getParameters()); + } + + /** + * @param int[] $conversationIDs + * @param int[] $labelIDs + */ + private function assignLabels(array $conversationIDs, array $labelIDs): void + { + if ($labelIDs === []) { + return; + } + + $sql = "INSERT INTO wcf1_conversation_label_to_object + (labelID, conversationID) + VALUES (?, ?)"; + $statement = WCF::getDB()->prepare($sql); + + foreach ($labelIDs as $labelID) { + foreach ($conversationIDs as $conversationID) { + $statement->execute([ + $labelID, + $conversationID, + ]); + } + } + } +} diff --git a/files/lib/system/endpoint/controller/core/conversations/AssignConversationLabels.class.php b/files/lib/system/endpoint/controller/core/conversations/AssignConversationLabels.class.php index f983ad67..561b738c 100644 --- a/files/lib/system/endpoint/controller/core/conversations/AssignConversationLabels.class.php +++ b/files/lib/system/endpoint/controller/core/conversations/AssignConversationLabels.class.php @@ -24,6 +24,8 @@ * @copyright 2001-2025 WoltLab GmbH * @license GNU Lesser General Public License * @since 6.2 + * + * TODO remove */ #[PostRequest('/core/conversations/assign-labels')] final class AssignConversationLabels implements IController diff --git a/files/lib/system/endpoint/controller/core/conversations/GetConversationLabels.class.php b/files/lib/system/endpoint/controller/core/conversations/GetConversationLabels.class.php index 1fbd2e6a..c17c071a 100644 --- a/files/lib/system/endpoint/controller/core/conversations/GetConversationLabels.class.php +++ b/files/lib/system/endpoint/controller/core/conversations/GetConversationLabels.class.php @@ -26,6 +26,8 @@ * @copyright 2001-2025 WoltLab GmbH * @license GNU Lesser General Public License * @since 6.2 + * + * TODO remove */ #[GetRequest('/core/conversations/labels')] final class GetConversationLabels implements IController diff --git a/files/lib/system/endpoint/controller/core/conversations/GetConversationLeaveDialog.class.php b/files/lib/system/endpoint/controller/core/conversations/GetConversationLeaveDialog.class.php index 78e1deba..70bd1b37 100644 --- a/files/lib/system/endpoint/controller/core/conversations/GetConversationLeaveDialog.class.php +++ b/files/lib/system/endpoint/controller/core/conversations/GetConversationLeaveDialog.class.php @@ -19,6 +19,8 @@ * @copyright 2001-2025 WoltLab GmbH * @license GNU Lesser General Public License * @since 6.2 + * + * TODO remove */ #[GetRequest('/core/conversations/{id:\d+}/leave-dialog')] final class GetConversationLeaveDialog implements IController diff --git a/files/lib/system/endpoint/controller/core/conversations/LeaveConversation.class.php b/files/lib/system/endpoint/controller/core/conversations/LeaveConversation.class.php index df9d14a8..b7220e60 100644 --- a/files/lib/system/endpoint/controller/core/conversations/LeaveConversation.class.php +++ b/files/lib/system/endpoint/controller/core/conversations/LeaveConversation.class.php @@ -19,6 +19,8 @@ * @copyright 2001-2025 WoltLab GmbH * @license GNU Lesser General Public License * @since 6.2 + * + * TODO remove */ #[PostRequest('/core/conversations/{id:\d+}/leave')] final class LeaveConversation implements IController diff --git a/files/lib/system/form/builder/field/ConversationLabelFormField.class.php b/files/lib/system/form/builder/field/ConversationLabelFormField.class.php new file mode 100644 index 00000000..47c045be --- /dev/null +++ b/files/lib/system/form/builder/field/ConversationLabelFormField.class.php @@ -0,0 +1,75 @@ + + * @since 6.2 + */ +final class ConversationLabelFormField extends AbstractFormField +{ + /** + * @var ConversationLabel[] + */ + public array $labels = []; + + /** + * @inheritDoc + */ + protected $javaScriptDataHandlerModule = 'WoltLabSuite/Core/Form/Builder/Field/Value'; + + /** + * @inheritDoc + */ + protected $templateName = 'shared_conversationLabelFormField'; + + /** + * @param ConversationLabel[] $labels + */ + public function labels(array $labels): static + { + $this->labels = $labels; + + return $this; + } + + /** + * @return ConversationLabel[] + */ + public function getLabels(): array + { + return $this->labels; + } + + #[\Override] + public function readValue() + { + if ($this->getDocument()->hasRequestData($this->getPrefixedId())) { + $this->value = \intval($this->getDocument()->getRequestData($this->getPrefixedId())); + } + + return $this; + } + + #[\Override] + public function validate() + { + if ($this->isRequired()) { + if ($this->value <= 0) { + $this->addValidationError(new FormFieldValidationError('empty')); + } + } elseif ($this->value > 0 && !\array_key_exists($this->value, $this->labels)) { + $this->addValidationError(new FormFieldValidationError( + 'invalidValue', + 'wcf.global.form.error.noValidSelection' + )); + } + } +} diff --git a/files/lib/system/interaction/bulk/user/ConversationBulkInteractions.class.php b/files/lib/system/interaction/bulk/user/ConversationBulkInteractions.class.php new file mode 100644 index 00000000..4eb7c4ac --- /dev/null +++ b/files/lib/system/interaction/bulk/user/ConversationBulkInteractions.class.php @@ -0,0 +1,46 @@ + + * @since 6.2 + */ +final class ConversationBulkInteractions extends AbstractBulkInteractionProvider +{ + public function __construct() + { + $labelList = ConversationLabel::getLabelsByUser(); + + $this->addInteractions([ + new BulkFormBuilderDialogInteraction( + 'assignLabel', + AssignConversationLabelDialogAction::class, + 'wcf.conversation.edit.assignLabel', + static fn () => $labelList->count() > 0, + ), + ]); + + EventHandler::getInstance()->fire( + new ConversationBulkInteractionCollecting($this) + ); + } + + #[\Override] + public function getObjectListClassName(): string + { + return ConversationList::class; + } +} diff --git a/files/lib/system/interaction/user/ConversationInteractions.class.php b/files/lib/system/interaction/user/ConversationInteractions.class.php new file mode 100644 index 00000000..4de1f507 --- /dev/null +++ b/files/lib/system/interaction/user/ConversationInteractions.class.php @@ -0,0 +1,54 @@ + + * @since 6.2 + */ +final class ConversationInteractions extends AbstractInteractionProvider +{ + public function __construct() + { + $labelList = ConversationLabel::getLabelsByUser(); + + $this->addInteractions([ + // TODO edit subject `wcf.conversation.edit.subject` + // TODO close `wcf.conversation.edit.close` + // TODO open `wcf.conversation.edit.open` + new FormBuilderDialogInteraction( + 'assignLabel', + LinkHandler::getInstance()->getControllerLink(AssignConversationLabelDialogAction::class, ['id' => '%s']), + 'wcf.conversation.edit.assignLabel', + static fn () => $labelList->count() > 0, + ), + new Divider(), + // TODO add a participant `wcf.conversation.edit.addParticipants` + // TODO leave `wcf.conversation.edit.leave` + ]); + + EventHandler::getInstance()->fire( + new ConversationInteractionCollecting($this) + ); + } + + #[\Override] + public function getObjectClassName(): string + { + return Conversation::class; + } +} diff --git a/files/lib/system/listView/user/ConversationListView.class.php b/files/lib/system/listView/user/ConversationListView.class.php new file mode 100644 index 00000000..832866c6 --- /dev/null +++ b/files/lib/system/listView/user/ConversationListView.class.php @@ -0,0 +1,127 @@ + + * @since 6.2 + * + * @extends AbstractListView + */ +final class ConversationListView extends AbstractListView +{ + public readonly string $filter; + + public function __construct(string $filter = '') + { + if ($filter === '' || \in_array($filter, UserConversationList::$availableFilters)) { + $this->filter = $filter; + } else { + $this->filter = ''; + } + + $this->addAvailableSortFields([ + new ListViewSortField('time', 'wcf.global.date'), + new ListViewSortField('subject', 'wcf.global.title'), + new ListViewSortField('lastPostTime', 'wcf.conversation.lastPostTime'), + new ListViewSortField('username', 'wcf.user.username'), + new ListViewSortField('replies', 'wcf.conversation.replies'), + new ListViewSortField('participants', 'wcf.conversation.participants'), + ]); + + $this->addAvailableFilters([ + new TextFilter('subject', 'wcf.global.title'), + new UserFilter('participants', 'wcf.conversation.participants'), + $this->getLabelFilter(), + ]); + + $this->setInteractionProvider(new ConversationInteractions()); + $this->setBulkInteractionProvider(new ConversationBulkInteractions()); + + $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"); + } + + #[\Override] + protected function createObjectList(): UserConversationList + { + return new UserConversationList(WCF::getUser()->userID, $this->filter); + } + + #[\Override] + public function renderItems(): string + { + return WCF::getTPL()->render('wcf', 'conversationListItems', ['view' => $this]); + } + + #[\Override] + protected function getInitializedEvent(): ConversationListViewInitialized + { + return new ConversationListViewInitialized($this); + } + + #[\Override] + public function getParameters(): array + { + return ['filter' => $this->filter]; + } + + private function getLabelFilter(): AbstractFilter + { + return new class extends AbstractFilter { + public readonly ConversationLabelList $labelList; + + public function __construct() + { + $this->labelList = ConversationLabel::getLabelsByUser(); + parent::__construct('label', 'wcf.label.label'); + } + + public function getFormField(): AbstractFormField + { + return ConversationLabelFormField::create('label') + ->label($this->languageItem) + ->labels($this->labelList->getObjects()); + } + + public function applyFilter(DatabaseObjectList $list, string $value): void + { + $list->getConditionBuilder()->add( + "{$list->getDatabaseTableAlias()}.{$list->getDatabaseTableIndexName()} IN ( + SELECT conversationID + FROM wcf1_conversation_label_to_object + WHERE labelID = ? + )", + [$value] + ); + } + + #[\Override] + public function renderValue(string $value): string + { + return $this->labelList->search((int)$value)->label; + } + }; + } +} diff --git a/files/style/conversation.scss b/files/style/conversation.scss index 1b981e90..f36e5d97 100644 --- a/files/style/conversation.scss +++ b/files/style/conversation.scss @@ -38,3 +38,8 @@ margin-left: 10px; } } + +.conversationList .columnInteractions { + display: flex; + flex-direction: column; +} diff --git a/templates/conversationList.tpl b/templates/conversationList.tpl index bd0dd3ca..b2f605a4 100644 --- a/templates/conversationList.tpl +++ b/templates/conversationList.tpl @@ -1,4 +1,4 @@ -{capture assign='pageTitle'}{if $filter}{lang}wcf.conversation.folder.{$filter}{/lang}{else}{$__wcf->getActivePage()->getTitle()}{/if}{if $pageNo > 1} - {lang}wcf.page.pageNo{/lang}{/if}{/capture} +{capture assign='pageTitle'}{if $filter}{lang}wcf.conversation.folder.{$filter}{/lang}{else}{$__wcf->getActivePage()->getTitle()}{/if}{/capture} {capture assign='contentHeader'}
@@ -27,7 +27,7 @@ {/capture} {capture assign='headContent'} - + {/capture} {capture assign='sidebarRight'} @@ -54,60 +54,6 @@ -
-

{lang}wcf.conversation.filter.participants{/lang}

- -
-
-
-
-
-
- -
- - {csrfToken} -
-
-
-
- -
-

{lang}wcf.conversation.label{/lang}

- -
- -
- -
- -
-
- {event name='beforeQuotaBox'}
@@ -130,276 +76,14 @@ {event name='boxes'} {/capture} -{capture assign='contentInteractionPagination'} - {assign var='participantsParameter' value=''} - {if $participants}{capture assign='participantsParameter'}&participants={implode from=$participants item=participant}{$participant|rawurlencode}{/implode}{/capture}{/if} - {assign var='labelIDParameter' value=''} - {if $labelID}{assign var='labelIDParameter' value="&labelID=$labelID"}{/if} - {pages print=true assign=pagesLinks controller='ConversationList' link="filter=$filter$participantsParameter&pageNo=%d&sortField=$sortField&sortOrder=$sortOrder$labelIDParameter"} -{/capture} - -{capture assign='contentInteractionButtons'} - -{/capture} - {capture assign='contentInteractionDropdownItems'} -
  • {lang}wcf.global.button.rss{/lang}
  • +
  • {lang}wcf.global.button.rss{/lang}
  • {/capture} {include file='header'} -{if !$items} - {lang}wcf.conversation.noConversations{/lang} -{else} -
    -
      -
    1. -
        -
      1. - -
      2. - -
      3. -
      -
    2. - - {foreach from=$objects item=conversation} -
    3. -
        -
      1. - -
      2. -
      3. - {if $conversation->getUserProfile()->getAvatar()} -
        - isNew()} title="{lang}wcf.conversation.markAsRead.doubleClick{/lang}"{/if}>{@$conversation->getUserProfile()->getAvatar()->getImageTag(48)}

        - - {if $conversation->ownPosts && $conversation->userID != $__wcf->user->userID} - {if $__wcf->getUserProfileHandler()->getAvatar()} - {@$__wcf->getUserProfileHandler()->getAvatar()->getImageTag(24)} - {/if} - {/if} -
        - {/if} -
      4. -
      5. - {hascontent} -
          - {content} - {foreach from=$conversation->getAssignedLabels() item=label} -
        • {$label->label}
        • - {/foreach} - {/content} -
        - {/hascontent} - -

        - {$conversation->subject} - {if $conversation->replies} - {@$conversation->replies|shortUnit} - {/if} -

        - - - - - -
          -
        • {$conversation->username}
        • -
        • {time time=$conversation->lastPostTime}
        • -
        - - {if $conversation->getParticipantSummary()|count} - - {assign var='participantSummaryCount' value=$conversation->getParticipantSummary()|count} - {lang}wcf.conversation.participants{/lang}: {implode from=$conversation->getParticipantSummary() item=participant}{$participant->username}{/implode} - {if $participantSummaryCount < $conversation->participants}{lang}wcf.conversation.participants.other{/lang}{/if} - - {/if} - - {event name='conversationData'} -
      6. -
      7. -
        -
        {lang}wcf.conversation.replies{/lang}
        -
        {@$conversation->replies|shortUnit}
        -
        -
        -
        {lang}wcf.conversation.participants{/lang}
        -
        {@$conversation->participants|shortUnit}
        -
        - -
        - {if $conversation->replies} - - {icon name='comment'} - - {@$conversation->replies|shortUnit} - {/if} -
        -
      8. -
      9. - {if $conversation->replies != 0 && $conversation->lastPostTime} -
        - {@$conversation->getLastPosterProfile()->getAvatar()->getImageTag(32)} - -
        -

        - {user object=$conversation->getLastPosterProfile()} -

        - {time time=$conversation->lastPostTime} -
        -
        - {/if} -
      10. - - {event name='columns'} -
      -
    4. - {/foreach} -
    -
    -{/if} - -
    - {hascontent} -
    - {content}{@$pagesLinks}{/content} -
    - {/hascontent} - - {hascontent} - - {/hascontent} -
    - - - - - +
    + {unsafe:$listView->render()} +
    {include file='footer'} diff --git a/templates/conversationListItems.tpl b/templates/conversationListItems.tpl new file mode 100644 index 00000000..d93421af --- /dev/null +++ b/templates/conversationListItems.tpl @@ -0,0 +1,121 @@ +{foreach from=$view->getItems() item=conversation} +
    +
      +
    1. + {if $view->hasBulkInteractions()} + + {/if} + + {unsafe:$view->renderInteractionContextMenuButton($conversation)} +
    2. +
    3. + {if $conversation->getUserProfile()->getAvatar()} +
      + isNew()} title="{lang}wcf.conversation.markAsRead.doubleClick{/lang}"{/if}>{unsafe:$conversation->getUserProfile()->getAvatar()->getImageTag(48)}

      + + {if $conversation->ownPosts && $conversation->userID != $__wcf->user->userID} + {if $__wcf->getUserProfileHandler()->getAvatar()} + {unsafe:$__wcf->getUserProfileHandler()->getAvatar()->getImageTag(24)} + {/if} + {/if} +
      + {/if} +
    4. +
    5. + {hascontent} +
        + {content} + {foreach from=$conversation->getAssignedLabels() item=label} +
      • {$label->label}
      • + {/foreach} + {/content} +
      + {/hascontent} + +

      + {$conversation->subject} + {if $conversation->replies} + {$conversation->replies|shortUnit} + {/if} +

      + + + +
        +
      • {user object=$conversation->getUserProfile()}
      • +
      • {time time=$conversation->time}
      • + {event name='messageGroupInfo'} +
      + +
        +
      • {$conversation->username}
      • +
      • {time time=$conversation->lastPostTime}
      • +
      + + {if $conversation->getParticipantSummary()|count} + + {assign var='participantSummaryCount' value=$conversation->getParticipantSummary()|count} + {lang}wcf.conversation.participants{/lang}: {implode from=$conversation->getParticipantSummary() item=participant}{$participant->username}{/implode} + {if $participantSummaryCount < $conversation->participants}{lang}wcf.conversation.participants.other{/lang}{/if} + + {/if} + + {event name='conversationData'} +
    6. +
    7. +
      +
      {lang}wcf.conversation.replies{/lang}
      +
      {$conversation->replies|shortUnit}
      +
      +
      +
      {lang}wcf.conversation.participants{/lang}
      +
      {$conversation->participants|shortUnit}
      +
      + +
      + {if $conversation->replies} + + {icon name='comment'} + + {$conversation->replies|shortUnit} + {/if} +
      +
    8. +
    9. + {if $conversation->replies != 0 && $conversation->lastPostTime} +
      + {@$conversation->getLastPosterProfile()->getAvatar()->getImageTag(32)} + +
      +

      + {user object=$conversation->getLastPosterProfile()} +

      + {time time=$conversation->lastPostTime} +
      +
      + {/if} +
    10. + + {event name='columns'} +
    +
    +{/foreach} diff --git a/templates/shared_conversationLabelFormField.tpl b/templates/shared_conversationLabelFormField.tpl new file mode 100644 index 00000000..101495cf --- /dev/null +++ b/templates/shared_conversationLabelFormField.tpl @@ -0,0 +1,38 @@ +
      + +
    + + + + From dbc04a2c72d50c39becccdc15e91b9021fe7ecbb Mon Sep 17 00:00:00 2001 From: Cyperghost Date: Wed, 25 Jun 2025 12:52:02 +0200 Subject: [PATCH 02/24] Implement interaction to edit conversation subject --- ...tSubjectConversationDialogAction.class.php | 90 +++++++++++++++++++ .../command/SetConversationSubject.class.php | 42 +++++++++ .../user/ConversationInteractions.class.php | 13 ++- 3 files changed, 142 insertions(+), 3 deletions(-) create mode 100644 files/lib/action/EditSubjectConversationDialogAction.class.php create mode 100644 files/lib/system/conversation/command/SetConversationSubject.class.php diff --git a/files/lib/action/EditSubjectConversationDialogAction.class.php b/files/lib/action/EditSubjectConversationDialogAction.class.php new file mode 100644 index 00000000..89fc33be --- /dev/null +++ b/files/lib/action/EditSubjectConversationDialogAction.class.php @@ -0,0 +1,90 @@ + + * @since 6.2 + */ +final class EditSubjectConversationDialogAction implements RequestHandlerInterface +{ + #[\Override] + public function handle(ServerRequestInterface $request): ResponseInterface + { + try { + $parameters = Helper::mapQueryParameters( + $request->getQueryParams(), + <<<'EOT' + array { + id: positive-int, + } + EOT + ); + } catch (MappingError) { + throw new IllegalLinkException(); + } + + $conversation = new Conversation($parameters['id']); + + if ($conversation->userID !== WCF::getUser()->userID) { + throw new PermissionDeniedException(); + } + + $form = $this->getForm($conversation); + + if ($request->getMethod() === 'GET') { + return $form->toResponse(); + } elseif ($request->getMethod() === 'POST') { + $response = $form->validateRequest($request); + if ($response !== null) { + return $response; + } + $data = $form->getData()['data']; + + (new SetConversationSubject($conversation, $data['subject']))(); + + return new JsonResponse([]); + } else { + throw new \LogicException('Unreachable'); + } + } + + private function getForm(Conversation $conversation): Psr15DialogForm + { + $form = new Psr15DialogForm( + static::class, + WCF::getLanguage()->get('wcf.conversation.edit.subject') + ); + + $form->appendChildren([ + TextFormField::create('subject') + ->label('wcf.global.subject') + ->maximumLength(255) + ->required(), + ]); + + $form->markRequiredFields(false); + $form->updatedObject($conversation); + $form->build(); + + return $form; + } +} diff --git a/files/lib/system/conversation/command/SetConversationSubject.class.php b/files/lib/system/conversation/command/SetConversationSubject.class.php new file mode 100644 index 00000000..048f35f5 --- /dev/null +++ b/files/lib/system/conversation/command/SetConversationSubject.class.php @@ -0,0 +1,42 @@ + + * @since 6.2 + */ +final class SetConversationSubject +{ + public function __construct( + public readonly Conversation $conversation, + public readonly string $subject, + ) { + } + + public function __invoke(): void + { + $editor = new ConversationEditor($this->conversation); + $editor->update([ + 'subject' => $this->subject, + ]); + + $message = $this->conversation->getFirstMessage(); + + SearchIndexManager::getInstance()->set( + 'com.woltlab.wcf.conversation.message', + $message->messageID, + $message->message, + $this->subject, + $message->time, + $message->userID, + $message->username + ); + } +} diff --git a/files/lib/system/interaction/user/ConversationInteractions.class.php b/files/lib/system/interaction/user/ConversationInteractions.class.php index 4de1f507..0860912c 100644 --- a/files/lib/system/interaction/user/ConversationInteractions.class.php +++ b/files/lib/system/interaction/user/ConversationInteractions.class.php @@ -3,14 +3,16 @@ namespace wcf\system\interaction\user; use wcf\action\AssignConversationLabelDialogAction; -use wcf\data\conversation\Conversation; +use wcf\action\EditSubjectConversationDialogAction; use wcf\data\conversation\label\ConversationLabel; +use wcf\data\conversation\ViewableConversation; use wcf\event\interaction\user\ConversationInteractionCollecting; use wcf\system\event\EventHandler; use wcf\system\interaction\AbstractInteractionProvider; use wcf\system\interaction\Divider; use wcf\system\interaction\FormBuilderDialogInteraction; use wcf\system\request\LinkHandler; +use wcf\system\WCF; /** * Interaction provider for conversations. @@ -27,7 +29,12 @@ public function __construct() $labelList = ConversationLabel::getLabelsByUser(); $this->addInteractions([ - // TODO edit subject `wcf.conversation.edit.subject` + new FormBuilderDialogInteraction( + 'editSubject', + LinkHandler::getInstance()->getControllerLink(EditSubjectConversationDialogAction::class, ['id' => '%s']), + 'wcf.conversation.edit.subject', + static fn (ViewableConversation $conversation) => WCF::getUser()->userID === $conversation->userID, + ), // TODO close `wcf.conversation.edit.close` // TODO open `wcf.conversation.edit.open` new FormBuilderDialogInteraction( @@ -49,6 +56,6 @@ public function __construct() #[\Override] public function getObjectClassName(): string { - return Conversation::class; + return ViewableConversation::class; } } From e5b4b3647a6db0ae4c55c1faa61658b8e985a0e0 Mon Sep 17 00:00:00 2001 From: Cyperghost Date: Wed, 25 Jun 2025 13:25:41 +0200 Subject: [PATCH 03/24] Refactor conversation list styles --- files/style/conversation.scss | 32 ++++++++++++++++++++++++++--- templates/conversationListItems.tpl | 8 ++++---- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/files/style/conversation.scss b/files/style/conversation.scss index f36e5d97..bcf0ef87 100644 --- a/files/style/conversation.scss +++ b/files/style/conversation.scss @@ -39,7 +39,33 @@ } } -.conversationList .columnInteractions { - display: flex; - flex-direction: column; +.conversationList { + .columnInteractions { + display: flex; + flex-direction: column; + } + + @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; + } + } + + /* revert style from `messageGroup.scss` */ + @include screen-md-up { + &.messageGroupList .pagination { + font-size: inherit; + } + } + .pagination { + flex: inherit; + opacity: inherit; + transition: inherit; + font-weight: inherit; + } } diff --git a/templates/conversationListItems.tpl b/templates/conversationListItems.tpl index d93421af..2be39db1 100644 --- a/templates/conversationListItems.tpl +++ b/templates/conversationListItems.tpl @@ -1,6 +1,6 @@ {foreach from=$view->getItems() item=conversation}
    -
      +
      1. {if $view->hasBulkInteractions()}
      2. -
      3. +
      4. {if $conversation->getUserProfile()->getAvatar()}
        isNew()} title="{lang}wcf.conversation.markAsRead.doubleClick{/lang}"{/if}>{unsafe:$conversation->getUserProfile()->getAvatar()->getImageTag(48)}

        @@ -23,7 +23,7 @@
        {/if}
      5. -
      6. +
      7. {hascontent}
          {content} @@ -103,7 +103,7 @@
        • {if $conversation->replies != 0 && $conversation->lastPostTime}
          - {@$conversation->getLastPosterProfile()->getAvatar()->getImageTag(32)} + {unsafe:$conversation->getLastPosterProfile()->getAvatar()->getImageTag(32)}

          From e56a1b182a11e47f23065142b0458ddfad36bed4 Mon Sep 17 00:00:00 2001 From: Cyperghost Date: Wed, 25 Jun 2025 14:00:56 +0200 Subject: [PATCH 04/24] Add dialog and command to add participants to conversations --- ...ticipantConversationDialogAction.class.php | 140 ++++++++++++++++++ files/lib/form/ConversationAddForm.class.php | 23 +-- .../AddParticipantConversation.class.php | 54 +++++++ .../user/ConversationInteractions.class.php | 8 +- 4 files changed, 215 insertions(+), 10 deletions(-) create mode 100644 files/lib/action/AddParticipantConversationDialogAction.class.php create mode 100644 files/lib/system/conversation/command/AddParticipantConversation.class.php diff --git a/files/lib/action/AddParticipantConversationDialogAction.class.php b/files/lib/action/AddParticipantConversationDialogAction.class.php new file mode 100644 index 00000000..d9d18d16 --- /dev/null +++ b/files/lib/action/AddParticipantConversationDialogAction.class.php @@ -0,0 +1,140 @@ + + * @since 6.2 + */ +final class AddParticipantConversationDialogAction implements RequestHandlerInterface +{ + #[\Override] + public function handle(ServerRequestInterface $request): ResponseInterface + { + try { + $parameters = Helper::mapQueryParameters( + $request->getQueryParams(), + <<<'EOT' + array { + id: positive-int, + } + EOT + ); + } catch (MappingError) { + throw new IllegalLinkException(); + } + + $conversation = new Conversation($parameters['id']); + + if (!Conversation::isParticipant([$conversation->conversationID]) || !$conversation->canAddParticipants()) { + throw new PermissionDeniedException(); + } + + $form = $this->getForm($conversation); + + if ($request->getMethod() === 'GET') { + return $form->toResponse(); + } elseif ($request->getMethod() === 'POST') { + $response = $form->validateRequest($request); + if ($response !== null) { + return $response; + } + + $data = $form->getData(); + + $messageVisibility = $data['data']['messageVisibility'] ?? 'new'; + $participants = $data['participants'] ?? []; + if (isset($data['participantGroups'])) { + $groupIDs = $data['participantGroups']; + $participants = \array_unique( + \array_merge( + $participants, + ConversationAddForm::getUserByGroups($groupIDs) + ) + ); + } + + (new AddParticipantConversation($conversation, $participants, $messageVisibility))(); + + return new JsonResponse([]); + } else { + throw new \LogicException('Unreachable'); + } + } + + private function getForm(Conversation $conversation): Psr15DialogForm + { + $form = new Psr15DialogForm( + static::class, + WCF::getLanguage()->get('wcf.conversation.edit.addParticipants') + ); + + $groupParticipants = \array_filter( + UserGroupCacheBuilder::getInstance()->getData([], 'groups'), + // @phpstan-ignore property.notFound + static fn (UserGroup $group) => $group->canBeAddedAsConversationParticipant + ); + + $form->appendChildren([ + UserFormField::create('participants') + ->label('wcf.conversation.participants') + ->description('wcf.conversation.participants.description') + ->maximumMultiples(WCF::getSession()->getPermission('user.conversation.maxParticipants')) + ->multiple() + ->maximumMultiples(WCF::getSession()->getPermission('user.conversation.maxParticipants') - $conversation->participants) + ->addValidator(ConversationAddForm::getParticipantsValidator()) + ->addValidator(ConversationAddForm::getMaximumParticipantsValidator(invisibleParticipantGroupsFieldId: null)), + BooleanFormField::create('addGroupParticipants') + ->label('wcf.conversation.addGroupParticipants') + ->available(\count($groupParticipants) > 0), + MultipleSelectionFormField::create('participantGroups') + ->label('wcf.conversation.participantGroups') + ->available(WCF::getSession()->getPermission('user.conversation.canAddGroupParticipants')) + ->filterable() + ->options($groupParticipants) + ->addDependency( + NonEmptyFormFieldDependency::create('addGroupParticipantsDependency') + ->fieldId('addGroupParticipants') + ), + RadioButtonFormField::create('messageVisibility') + ->label('wcf.conversation.visibility') + ->available(!$conversation->isDraft && $conversation->canAddParticipantsUnrestricted()) + ->required() + ->options([ + 'all' => 'wcf.conversation.visibility.all', + 'new' => 'wcf.conversation.visibility.new', + ]) + ->value('all'), + ]); + + $form->markRequiredFields(false); + $form->build(); + + return $form; + } +} diff --git a/files/lib/form/ConversationAddForm.class.php b/files/lib/form/ConversationAddForm.class.php index 5803f874..28e305c1 100644 --- a/files/lib/form/ConversationAddForm.class.php +++ b/files/lib/form/ConversationAddForm.class.php @@ -350,7 +350,7 @@ public static function getParticipantsValidator(): FormFieldValidator public static function getMaximumParticipantsValidator( string $invisibleParticipantsFieldId = 'invisibleParticipants', string $participantGroupsFieldId = 'participantGroups', - string $invisibleParticipantGroupsFieldId = 'invisibleParticipantGroups' + ?string $invisibleParticipantGroupsFieldId = 'invisibleParticipantGroups' ): FormFieldValidator { return new FormFieldValidator( 'participantsMaximumValidator', @@ -359,18 +359,19 @@ static function (UserFormField $formField) use ( $participantGroupsFieldId, $invisibleParticipantGroupsFieldId ) { - /** - * @var UserFormField|null $invisibleParticipantsFormField - * @var MultipleSelectionFormField|null $participantGroupsFormField - * @var MultipleSelectionFormField|null $invisibleParticipantGroupsFormField - */ - $invisibleParticipantsFormField = $formField->getDocument() ->getNodeById($invisibleParticipantsFieldId); $participantGroupsFormField = $formField->getDocument() ->getNodeById($participantGroupsFieldId); - $invisibleParticipantGroupsFormField = $formField->getDocument() - ->getNodeById($invisibleParticipantGroupsFieldId); + $isDraftFormField = $formField->getDocument()->getNodeById('isDraft'); + $invisibleParticipantGroupsFormField = $invisibleParticipantGroupsFieldId !== null ? $formField->getDocument() + ->getNodeById($invisibleParticipantGroupsFieldId) : null; + + \assert($invisibleParticipantsFormField === null || $invisibleParticipantsFormField instanceof UserFormField); + \assert($isDraftFormField === null || $isDraftFormField instanceof BooleanFormField); + \assert($participantGroupsFormField === null || $participantGroupsFormField instanceof MultipleSelectionFormField); + \assert($invisibleParticipantGroupsFormField === null || $invisibleParticipantGroupsFormField instanceof MultipleSelectionFormField); + $groupIDs = \array_merge( $participantGroupsFormField?->getValue() ?: [], $invisibleParticipantGroupsFormField?->getValue() ?: [], @@ -389,6 +390,10 @@ static function (UserFormField $formField) use ( ) ); } + + if ($userIDs === []) { + $formField->addValidationError(new FormFieldValidationError('empty')); + } } ); } diff --git a/files/lib/system/conversation/command/AddParticipantConversation.class.php b/files/lib/system/conversation/command/AddParticipantConversation.class.php new file mode 100644 index 00000000..e650b30e --- /dev/null +++ b/files/lib/system/conversation/command/AddParticipantConversation.class.php @@ -0,0 +1,54 @@ + + * @since 6.2 + */ +final class AddParticipantConversation +{ + public function __construct( + public readonly Conversation $conversation, + /** + * @var int[] + */ + public readonly array $participants, + /** + * @var 'new'|'all' + */ + public readonly ?string $messageVisibility + ) { + } + + public function __invoke(): void + { + if ($this->conversation->isDraft) { + $draftData = \unserialize($this->conversation->draftData); + $draftData['participants'] = \array_merge($draftData['participants'], $this->participants); + $data = ['data' => ['draftData' => \serialize($draftData)]]; + } else { + $data = [ + 'participants' => $this->participants, + 'visibility' => $this->messageVisibility, + ]; + } + + (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/interaction/user/ConversationInteractions.class.php b/files/lib/system/interaction/user/ConversationInteractions.class.php index 0860912c..3c1a1ca9 100644 --- a/files/lib/system/interaction/user/ConversationInteractions.class.php +++ b/files/lib/system/interaction/user/ConversationInteractions.class.php @@ -2,6 +2,7 @@ namespace wcf\system\interaction\user; +use wcf\action\AddParticipantConversationDialogAction; use wcf\action\AssignConversationLabelDialogAction; use wcf\action\EditSubjectConversationDialogAction; use wcf\data\conversation\label\ConversationLabel; @@ -44,7 +45,12 @@ public function __construct() static fn () => $labelList->count() > 0, ), new Divider(), - // TODO add a participant `wcf.conversation.edit.addParticipants` + new FormBuilderDialogInteraction( + 'addParticipants', + LinkHandler::getInstance()->getControllerLink(AddParticipantConversationDialogAction::class, ['id' => '%s']), + 'wcf.conversation.edit.addParticipants', + static fn (ViewableConversation $conversation) => $conversation->canAddParticipants(), + ), // TODO leave `wcf.conversation.edit.leave` ]); From f8b688e4f650278e262ee377279ed5a074201a2a Mon Sep 17 00:00:00 2001 From: Cyperghost Date: Wed, 25 Jun 2025 14:20:49 +0200 Subject: [PATCH 05/24] Add endpoints for leaving and restoring conversations --- .../com.woltlab.wcf.conversation.php | 2 + .../conversations/LeaveConversation.class.php | 24 +---------- .../LeavePermanentlyConversation.class.php | 42 +++++++++++++++++++ .../RestoreConversation.class.php | 42 +++++++++++++++++++ .../user/ConversationInteractions.class.php | 24 ++++++++++- 5 files changed, 111 insertions(+), 23 deletions(-) create mode 100644 files/lib/system/endpoint/controller/core/conversations/LeavePermanentlyConversation.class.php create mode 100644 files/lib/system/endpoint/controller/core/conversations/RestoreConversation.class.php diff --git a/files/lib/bootstrap/com.woltlab.wcf.conversation.php b/files/lib/bootstrap/com.woltlab.wcf.conversation.php index b0267675..0fe63bba 100644 --- a/files/lib/bootstrap/com.woltlab.wcf.conversation.php +++ b/files/lib/bootstrap/com.woltlab.wcf.conversation.php @@ -41,6 +41,8 @@ static function (\wcf\event\user\profile\UserProfileHeaderInteractionOptionColle static function (\wcf\event\endpoint\ControllerCollecting $event) { $event->register(new \wcf\system\endpoint\controller\core\conversations\GetConversationPopover()); $event->register(new \wcf\system\endpoint\controller\core\conversations\LeaveConversation()); + $event->register(new \wcf\system\endpoint\controller\core\conversations\LeavePermanentlyConversation()); + $event->register(new \wcf\system\endpoint\controller\core\conversations\RestoreConversation()); $event->register(new \wcf\system\endpoint\controller\core\conversations\GetConversationLeaveDialog()); $event->register(new \wcf\system\endpoint\controller\core\conversations\GetConversationLabels()); $event->register(new \wcf\system\endpoint\controller\core\conversations\AssignConversationLabels()); diff --git a/files/lib/system/endpoint/controller/core/conversations/LeaveConversation.class.php b/files/lib/system/endpoint/controller/core/conversations/LeaveConversation.class.php index b7220e60..c08f7e90 100644 --- a/files/lib/system/endpoint/controller/core/conversations/LeaveConversation.class.php +++ b/files/lib/system/endpoint/controller/core/conversations/LeaveConversation.class.php @@ -10,7 +10,6 @@ use wcf\system\endpoint\IController; use wcf\system\endpoint\PostRequest; use wcf\system\exception\PermissionDeniedException; -use wcf\system\request\LinkHandler; /** * API endpoint for leaving a conversation. @@ -19,8 +18,6 @@ * @copyright 2001-2025 WoltLab GmbH * @license GNU Lesser General Public License * @since 6.2 - * - * TODO remove */ #[PostRequest('/core/conversations/{id:\d+}/leave')] final class LeaveConversation implements IController @@ -31,14 +28,9 @@ public function __invoke(ServerRequestInterface $request, array $variables): Res $conversation = Helper::fetchObjectFromRequestParameter($variables['id'], Conversation::class); $this->assertConversationIsAccessible($conversation); - $parameters = Helper::mapApiParameters($request, LeaveConversationParameters::class); - $hideConversation = $parameters->hideConversation; - - (new \wcf\system\conversation\command\LeaveConversation([$conversation->conversationID], $hideConversation))(); + (new \wcf\system\conversation\command\LeaveConversation([$conversation->conversationID], Conversation::STATE_HIDDEN))(); - return new JsonResponse([ - 'redirectUrl' => LinkHandler::getInstance()->getLink('ConversationList'), - ]); + return new JsonResponse([]); } private function assertConversationIsAccessible(Conversation $conversation): void @@ -48,15 +40,3 @@ private function assertConversationIsAccessible(Conversation $conversation): voi } } } - -// @codingStandardsIgnoreStart -/** @internal */ -final class LeaveConversationParameters -{ - public function __construct( - /** @var Conversation::STATE_DEFAULT|Conversation::STATE_HIDDEN|Conversation::STATE_LEFT */ - public readonly int $hideConversation, - ) { - } -} -// @codingStandardsIgnoreEnd diff --git a/files/lib/system/endpoint/controller/core/conversations/LeavePermanentlyConversation.class.php b/files/lib/system/endpoint/controller/core/conversations/LeavePermanentlyConversation.class.php new file mode 100644 index 00000000..731aa391 --- /dev/null +++ b/files/lib/system/endpoint/controller/core/conversations/LeavePermanentlyConversation.class.php @@ -0,0 +1,42 @@ + + * @since 6.2 + */ +#[PostRequest('/core/conversations/{id:\d+}/leave-permanently')] +final class LeavePermanentlyConversation implements IController +{ + #[\Override] + public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface + { + $conversation = Helper::fetchObjectFromRequestParameter($variables['id'], Conversation::class); + $this->assertConversationIsAccessible($conversation); + + (new \wcf\system\conversation\command\LeaveConversation([$conversation->conversationID], Conversation::STATE_LEFT))(); + + return new JsonResponse([]); + } + + private function assertConversationIsAccessible(Conversation $conversation): void + { + if (!Conversation::isParticipant([$conversation->conversationID])) { + throw new PermissionDeniedException(); + } + } +} diff --git a/files/lib/system/endpoint/controller/core/conversations/RestoreConversation.class.php b/files/lib/system/endpoint/controller/core/conversations/RestoreConversation.class.php new file mode 100644 index 00000000..de62d829 --- /dev/null +++ b/files/lib/system/endpoint/controller/core/conversations/RestoreConversation.class.php @@ -0,0 +1,42 @@ + + * @since 6.2 + */ +#[PostRequest('/core/conversations/{id:\d+}/restore')] +final class RestoreConversation implements IController +{ + #[\Override] + public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface + { + $conversation = Helper::fetchObjectFromRequestParameter($variables['id'], Conversation::class); + $this->assertConversationIsAccessible($conversation); + + (new \wcf\system\conversation\command\LeaveConversation([$conversation->conversationID], Conversation::STATE_DEFAULT))(); + + return new JsonResponse([]); + } + + private function assertConversationIsAccessible(Conversation $conversation): void + { + if (!Conversation::isParticipant([$conversation->conversationID])) { + throw new PermissionDeniedException(); + } + } +} diff --git a/files/lib/system/interaction/user/ConversationInteractions.class.php b/files/lib/system/interaction/user/ConversationInteractions.class.php index 3c1a1ca9..aa1c0669 100644 --- a/files/lib/system/interaction/user/ConversationInteractions.class.php +++ b/files/lib/system/interaction/user/ConversationInteractions.class.php @@ -12,6 +12,8 @@ use wcf\system\interaction\AbstractInteractionProvider; use wcf\system\interaction\Divider; use wcf\system\interaction\FormBuilderDialogInteraction; +use wcf\system\interaction\InteractionConfirmationType; +use wcf\system\interaction\RpcInteraction; use wcf\system\request\LinkHandler; use wcf\system\WCF; @@ -51,7 +53,27 @@ public function __construct() 'wcf.conversation.edit.addParticipants', static fn (ViewableConversation $conversation) => $conversation->canAddParticipants(), ), - // TODO leave `wcf.conversation.edit.leave` + new RpcInteraction( + 'restore', + 'core/conversations/%s/restore', + 'wcf.conversation.hideConversation.restore', + isAvailableCallback: static fn (ViewableConversation $conversation) => $conversation->hideConversation + ), + new RpcInteraction( + 'leave', + 'core/conversations/%s/leave', + 'wcf.conversation.hideConversation.leave', + InteractionConfirmationType::Custom, + 'wcf.conversation.hideConversation.leave.description', + static fn (ViewableConversation $conversation) => !$conversation->hideConversation + ), + new RpcInteraction( + 'leave-permanently', + 'core/conversations/%s/leave-permanently', + 'wcf.conversation.hideConversation.leavePermanently', + InteractionConfirmationType::Custom, + 'wcf.conversation.hideConversation.leavePermanently.description', + ), ]); EventHandler::getInstance()->fire( From 5575393737333d1a8be9bb231d994cd49e88ede0 Mon Sep 17 00:00:00 2001 From: Cyperghost Date: Wed, 25 Jun 2025 14:27:35 +0200 Subject: [PATCH 06/24] Add API endpoints to open and close conversations --- .../com.woltlab.wcf.conversation.php | 2 + .../conversations/CloseConversation.class.php | 59 +++++++++++++++++++ .../conversations/OpenConversation.class.php | 59 +++++++++++++++++++ .../user/ConversationInteractions.class.php | 14 ++++- 4 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 files/lib/system/endpoint/controller/core/conversations/CloseConversation.class.php create mode 100644 files/lib/system/endpoint/controller/core/conversations/OpenConversation.class.php diff --git a/files/lib/bootstrap/com.woltlab.wcf.conversation.php b/files/lib/bootstrap/com.woltlab.wcf.conversation.php index 0fe63bba..62edf5df 100644 --- a/files/lib/bootstrap/com.woltlab.wcf.conversation.php +++ b/files/lib/bootstrap/com.woltlab.wcf.conversation.php @@ -43,6 +43,8 @@ static function (\wcf\event\endpoint\ControllerCollecting $event) { $event->register(new \wcf\system\endpoint\controller\core\conversations\LeaveConversation()); $event->register(new \wcf\system\endpoint\controller\core\conversations\LeavePermanentlyConversation()); $event->register(new \wcf\system\endpoint\controller\core\conversations\RestoreConversation()); + $event->register(new \wcf\system\endpoint\controller\core\conversations\OpenConversation()); + $event->register(new \wcf\system\endpoint\controller\core\conversations\CloseConversation()); $event->register(new \wcf\system\endpoint\controller\core\conversations\GetConversationLeaveDialog()); $event->register(new \wcf\system\endpoint\controller\core\conversations\GetConversationLabels()); $event->register(new \wcf\system\endpoint\controller\core\conversations\AssignConversationLabels()); diff --git a/files/lib/system/endpoint/controller/core/conversations/CloseConversation.class.php b/files/lib/system/endpoint/controller/core/conversations/CloseConversation.class.php new file mode 100644 index 00000000..f3fc2f09 --- /dev/null +++ b/files/lib/system/endpoint/controller/core/conversations/CloseConversation.class.php @@ -0,0 +1,59 @@ + + * @since 6.2 + */ +#[PostRequest('/core/conversations/{id:\d+}/close')] +final class CloseConversation implements IController +{ + #[\Override] + public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface + { + $conversation = Helper::fetchObjectFromRequestParameter($variables['id'], Conversation::class); + $this->assertConversationCanClosed($conversation); + + if (!$conversation->isClosed) { + $this->openConversation($conversation); + } + + return new JsonResponse([]); + } + + private function assertConversationCanClosed(Conversation $conversation): void + { + if (!Conversation::isParticipant([$conversation->conversationID])) { + throw new PermissionDeniedException(); + } + + if ($conversation->userID !== WCF::getUser()->userID) { + throw new PermissionDeniedException(); + } + } + + private function openConversation(Conversation $conversation): void + { + $editor = new ConversationEditor($conversation); + $editor->update(['isClosed' => 1]); + + ConversationModificationLogHandler::getInstance()->close($conversation); + } +} diff --git a/files/lib/system/endpoint/controller/core/conversations/OpenConversation.class.php b/files/lib/system/endpoint/controller/core/conversations/OpenConversation.class.php new file mode 100644 index 00000000..c1d2c0f6 --- /dev/null +++ b/files/lib/system/endpoint/controller/core/conversations/OpenConversation.class.php @@ -0,0 +1,59 @@ + + * @since 6.2 + */ +#[PostRequest('/core/conversations/{id:\d+}/open')] +final class OpenConversation implements IController +{ + #[\Override] + public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface + { + $conversation = Helper::fetchObjectFromRequestParameter($variables['id'], Conversation::class); + $this->assertConversationCanOpened($conversation); + + if ($conversation->isClosed) { + $this->openConversation($conversation); + } + + return new JsonResponse([]); + } + + private function assertConversationCanOpened(Conversation $conversation): void + { + if (!Conversation::isParticipant([$conversation->conversationID])) { + throw new PermissionDeniedException(); + } + + if ($conversation->userID !== WCF::getUser()->userID) { + throw new PermissionDeniedException(); + } + } + + private function openConversation(Conversation $conversation): void + { + $editor = new ConversationEditor($conversation); + $editor->update(['isClosed' => 0]); + + ConversationModificationLogHandler::getInstance()->open($conversation); + } +} diff --git a/files/lib/system/interaction/user/ConversationInteractions.class.php b/files/lib/system/interaction/user/ConversationInteractions.class.php index aa1c0669..f9c0c5d0 100644 --- a/files/lib/system/interaction/user/ConversationInteractions.class.php +++ b/files/lib/system/interaction/user/ConversationInteractions.class.php @@ -38,8 +38,18 @@ public function __construct() 'wcf.conversation.edit.subject', static fn (ViewableConversation $conversation) => WCF::getUser()->userID === $conversation->userID, ), - // TODO close `wcf.conversation.edit.close` - // TODO open `wcf.conversation.edit.open` + new RpcInteraction( + 'open', + 'core/conversations/%s/open', + 'wcf.conversation.edit.open', + isAvailableCallback: static fn (ViewableConversation $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->isClosed && $conversation->userID === WCF::getUser()->userID + ), new FormBuilderDialogInteraction( 'assignLabel', LinkHandler::getInstance()->getControllerLink(AssignConversationLabelDialogAction::class, ['id' => '%s']), From ac46db8b3888a23d12b611dca505243e66b929b9 Mon Sep 17 00:00:00 2001 From: Cyperghost Date: Thu, 26 Jun 2025 10:17:28 +0200 Subject: [PATCH 07/24] Add bulk conversation interactions --- .../UserConversationList.class.php | 10 ++--- .../ConversationBulkInteractions.class.php | 42 ++++++++++++++++++- .../user/ConversationInteractions.class.php | 32 ++++++++++---- 3 files changed, 70 insertions(+), 14 deletions(-) diff --git a/files/lib/data/conversation/UserConversationList.class.php b/files/lib/data/conversation/UserConversationList.class.php index f4e0fc20..eb1cacf4 100644 --- a/files/lib/data/conversation/UserConversationList.class.php +++ b/files/lib/data/conversation/UserConversationList.class.php @@ -45,13 +45,13 @@ class UserConversationList extends ConversationList /** * Creates a new UserConversationList - * - * @param int $userID - * @param string $filter - * @param int $labelID */ - public function __construct($userID, $filter = '', $labelID = 0) + public function __construct(?int $userID = null, string $filter = '', ?int $labelID = null) { + if (!$userID) { + $userID = WCF::getUser()->userID; + } + parent::__construct(); $this->filter = $filter; diff --git a/files/lib/system/interaction/bulk/user/ConversationBulkInteractions.class.php b/files/lib/system/interaction/bulk/user/ConversationBulkInteractions.class.php index 4eb7c4ac..bbe6f4e1 100644 --- a/files/lib/system/interaction/bulk/user/ConversationBulkInteractions.class.php +++ b/files/lib/system/interaction/bulk/user/ConversationBulkInteractions.class.php @@ -3,12 +3,16 @@ namespace wcf\system\interaction\bulk\user; use wcf\action\AssignConversationLabelDialogAction; -use wcf\data\conversation\ConversationList; 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; use wcf\system\interaction\bulk\BulkFormBuilderDialogInteraction; +use wcf\system\interaction\bulk\BulkRpcInteraction; +use wcf\system\interaction\InteractionConfirmationType; +use wcf\system\WCF; /** * Bulk interaction provider for conversations. @@ -25,12 +29,46 @@ public function __construct() $labelList = ConversationLabel::getLabelsByUser(); $this->addInteractions([ + new BulkRpcInteraction( + 'open', + 'core/conversations/%s/open', + 'wcf.conversation.edit.open', + isAvailableCallback: static fn (ViewableConversation $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 + ), new BulkFormBuilderDialogInteraction( 'assignLabel', AssignConversationLabelDialogAction::class, 'wcf.conversation.edit.assignLabel', static fn () => $labelList->count() > 0, ), + new BulkRpcInteraction( + 'restore', + 'core/conversations/%s/restore', + 'wcf.conversation.hideConversation.restore', + InteractionConfirmationType::Custom, + isAvailableCallback: static fn (ViewableConversation $conversation) => (bool)$conversation->hideConversation + ), + new BulkRpcInteraction( + 'leave', + 'core/conversations/%s/leave', + 'wcf.conversation.hideConversation.leave', + InteractionConfirmationType::Custom, + 'wcf.conversation.hideConversation.leave.description', + static fn (ViewableConversation $conversation) => !$conversation->hideConversation + ), + new BulkRpcInteraction( + 'leave-permanently', + 'core/conversations/%s/leave-permanently', + 'wcf.conversation.hideConversation.leavePermanently', + InteractionConfirmationType::Custom, + 'wcf.conversation.hideConversation.leavePermanently.description', + ), ]); EventHandler::getInstance()->fire( @@ -41,6 +79,6 @@ public function __construct() #[\Override] public function getObjectListClassName(): string { - return ConversationList::class; + return UserConversationList::class; } } diff --git a/files/lib/system/interaction/user/ConversationInteractions.class.php b/files/lib/system/interaction/user/ConversationInteractions.class.php index f9c0c5d0..9fb93e55 100644 --- a/files/lib/system/interaction/user/ConversationInteractions.class.php +++ b/files/lib/system/interaction/user/ConversationInteractions.class.php @@ -5,9 +5,11 @@ use wcf\action\AddParticipantConversationDialogAction; use wcf\action\AssignConversationLabelDialogAction; 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\system\cache\runtime\UserConversationRuntimeCache; use wcf\system\event\EventHandler; use wcf\system\interaction\AbstractInteractionProvider; use wcf\system\interaction\Divider; @@ -36,19 +38,19 @@ public function __construct() 'editSubject', LinkHandler::getInstance()->getControllerLink(EditSubjectConversationDialogAction::class, ['id' => '%s']), 'wcf.conversation.edit.subject', - static fn (ViewableConversation $conversation) => WCF::getUser()->userID === $conversation->userID, + static fn (ViewableConversation|Conversation $conversation) => WCF::getUser()->userID === $conversation->userID, ), new RpcInteraction( 'open', 'core/conversations/%s/open', 'wcf.conversation.edit.open', - isAvailableCallback: static fn (ViewableConversation $conversation) => $conversation->isClosed && $conversation->userID === WCF::getUser()->userID + isAvailableCallback: static fn (ViewableConversation|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->isClosed && $conversation->userID === WCF::getUser()->userID + isAvailableCallback: static fn (ViewableConversation|Conversation $conversation) => !$conversation->isClosed && $conversation->userID === WCF::getUser()->userID ), new FormBuilderDialogInteraction( 'assignLabel', @@ -61,13 +63,21 @@ public function __construct() 'addParticipants', LinkHandler::getInstance()->getControllerLink(AddParticipantConversationDialogAction::class, ['id' => '%s']), 'wcf.conversation.edit.addParticipants', - static fn (ViewableConversation $conversation) => $conversation->canAddParticipants(), + static fn (ViewableConversation|Conversation $conversation) => $conversation->canAddParticipants(), ), new RpcInteraction( 'restore', 'core/conversations/%s/restore', 'wcf.conversation.hideConversation.restore', - isAvailableCallback: static fn (ViewableConversation $conversation) => $conversation->hideConversation + InteractionConfirmationType::Custom, + isAvailableCallback: static function (ViewableConversation|Conversation $conversation) { + if (!($conversation instanceof ViewableConversation)) { + $conversation = UserConversationRuntimeCache::getInstance()->getObject($conversation->conversationID); + } + + return (bool)$conversation->hideConversation; + }, + invalidatesAllItems: true ), new RpcInteraction( 'leave', @@ -75,7 +85,14 @@ public function __construct() 'wcf.conversation.hideConversation.leave', InteractionConfirmationType::Custom, 'wcf.conversation.hideConversation.leave.description', - static fn (ViewableConversation $conversation) => !$conversation->hideConversation + static function (ViewableConversation|Conversation $conversation) { + if (!($conversation instanceof ViewableConversation)) { + $conversation = UserConversationRuntimeCache::getInstance()->getObject($conversation->conversationID); + } + + return !$conversation->hideConversation; + }, + true ), new RpcInteraction( 'leave-permanently', @@ -83,6 +100,7 @@ public function __construct() 'wcf.conversation.hideConversation.leavePermanently', InteractionConfirmationType::Custom, 'wcf.conversation.hideConversation.leavePermanently.description', + invalidatesAllItems: true ), ]); @@ -94,6 +112,6 @@ public function __construct() #[\Override] public function getObjectClassName(): string { - return ViewableConversation::class; + return Conversation::class; } } From fe9adc4b94798004cd43c844ac903d13f2267604 Mon Sep 17 00:00:00 2001 From: Cyperghost Date: Thu, 26 Jun 2025 10:37:20 +0200 Subject: [PATCH 08/24] Use standalone interaction context menu in conversation page --- .../com.woltlab.wcf.conversation.php | 1 + files/lib/form/ConversationAddForm.class.php | 3 +- files/lib/page/ConversationPage.class.php | 9 ++++ .../UserConversationRuntimeCache.class.php | 4 +- .../GetConversationHeaderTitle.class.php | 50 +++++++++++++++++ .../user/ConversationInteractions.class.php | 8 +++ language/de.xml | 1 + language/en.xml | 1 + templates/conversation.tpl | 54 ++----------------- templates/conversationContentHeaderTitle.tpl | 37 +++++++++++++ 10 files changed, 115 insertions(+), 53 deletions(-) create mode 100644 files/lib/system/endpoint/controller/core/conversations/GetConversationHeaderTitle.class.php create mode 100644 templates/conversationContentHeaderTitle.tpl diff --git a/files/lib/bootstrap/com.woltlab.wcf.conversation.php b/files/lib/bootstrap/com.woltlab.wcf.conversation.php index 62edf5df..5ee70c3d 100644 --- a/files/lib/bootstrap/com.woltlab.wcf.conversation.php +++ b/files/lib/bootstrap/com.woltlab.wcf.conversation.php @@ -45,6 +45,7 @@ static function (\wcf\event\endpoint\ControllerCollecting $event) { $event->register(new \wcf\system\endpoint\controller\core\conversations\RestoreConversation()); $event->register(new \wcf\system\endpoint\controller\core\conversations\OpenConversation()); $event->register(new \wcf\system\endpoint\controller\core\conversations\CloseConversation()); + $event->register(new \wcf\system\endpoint\controller\core\conversations\GetConversationHeaderTitle()); $event->register(new \wcf\system\endpoint\controller\core\conversations\GetConversationLeaveDialog()); $event->register(new \wcf\system\endpoint\controller\core\conversations\GetConversationLabels()); $event->register(new \wcf\system\endpoint\controller\core\conversations\AssignConversationLabels()); diff --git a/files/lib/form/ConversationAddForm.class.php b/files/lib/form/ConversationAddForm.class.php index 28e305c1..91f21a79 100644 --- a/files/lib/form/ConversationAddForm.class.php +++ b/files/lib/form/ConversationAddForm.class.php @@ -376,6 +376,7 @@ static function (UserFormField $formField) use ( $participantGroupsFormField?->getValue() ?: [], $invisibleParticipantGroupsFormField?->getValue() ?: [], ); + $userIDs = \array_merge( \array_column($formField->getUsers(), 'userID'), \array_column($invisibleParticipantsFormField?->getUsers() ?: [], 'userID'), @@ -391,7 +392,7 @@ static function (UserFormField $formField) use ( ); } - if ($userIDs === []) { + if (!$isDraftFormField?->getValue() && $userIDs === []) { $formField->addValidationError(new FormFieldValidationError('empty')); } } diff --git a/files/lib/page/ConversationPage.class.php b/files/lib/page/ConversationPage.class.php index 931be5f3..a3245d52 100644 --- a/files/lib/page/ConversationPage.class.php +++ b/files/lib/page/ConversationPage.class.php @@ -17,6 +17,8 @@ use wcf\system\bbcode\BBCodeHandler; use wcf\system\exception\IllegalLinkException; use wcf\system\exception\PermissionDeniedException; +use wcf\system\interaction\StandaloneInteractionContextMenuComponent; +use wcf\system\interaction\user\ConversationInteractions; use wcf\system\message\quote\MessageQuoteManager; use wcf\system\page\PageLocationManager; use wcf\system\page\ParentPageLocation; @@ -355,6 +357,13 @@ public function assignVariables() 'conversationID' => $this->conversationID, 'participants' => $this->participantList->getObjects(), 'defaultSmilies' => SmileyCache::getInstance()->getCategorySmilies(), + 'interactionContextMenu' => StandaloneInteractionContextMenuComponent::forContentInteractionButton( + new ConversationInteractions(), + $this->conversation, + LinkHandler::getInstance()->getControllerLink(ConversationListPage::class), + WCF::getLanguage()->getDynamicVariable('wcf.conversation.edit.conversation'), + "core/conversations/{$this->conversationID}/content-header-title" + ), ]); BBCodeHandler::getInstance()->setDisallowedBBCodes(\explode( diff --git a/files/lib/system/cache/runtime/UserConversationRuntimeCache.class.php b/files/lib/system/cache/runtime/UserConversationRuntimeCache.class.php index 34e6ed2c..a99619aa 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; /** @@ -14,7 +14,7 @@ * @license GNU Lesser General Public License * @since 3.0 * - * @extends AbstractRuntimeCache + * @extends AbstractRuntimeCache */ class UserConversationRuntimeCache extends AbstractRuntimeCache { diff --git a/files/lib/system/endpoint/controller/core/conversations/GetConversationHeaderTitle.class.php b/files/lib/system/endpoint/controller/core/conversations/GetConversationHeaderTitle.class.php new file mode 100644 index 00000000..7bad60bd --- /dev/null +++ b/files/lib/system/endpoint/controller/core/conversations/GetConversationHeaderTitle.class.php @@ -0,0 +1,50 @@ + + * @since 6.2 + */ +#[GetRequest('/core/conversations/{id:\d+}/content-header-title')] +final class GetConversationHeaderTitle implements IController +{ + #[\Override] + public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface + { + $conversation = UserConversationRuntimeCache::getInstance()->getObject(\intval($variables['id'])); + if ($conversation === null) { + throw new IllegalLinkException(); + } + + $this->assertConversationIsAccessible($conversation); + + return new JsonResponse([ + 'template' => WCF::getTPL()->render('wcf', 'conversationContentHeaderTitle', [ + 'conversation' => $conversation, + ]), + ]); + } + + private function assertConversationIsAccessible(ViewableConversation $conversation): void + { + if (!$conversation->isActiveParticipant()) { + throw new PermissionDeniedException(); + } + } +} diff --git a/files/lib/system/interaction/user/ConversationInteractions.class.php b/files/lib/system/interaction/user/ConversationInteractions.class.php index 9fb93e55..d9ea7e51 100644 --- a/files/lib/system/interaction/user/ConversationInteractions.class.php +++ b/files/lib/system/interaction/user/ConversationInteractions.class.php @@ -9,10 +9,12 @@ 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; +use wcf\system\interaction\EditInteraction; use wcf\system\interaction\FormBuilderDialogInteraction; use wcf\system\interaction\InteractionConfirmationType; use wcf\system\interaction\RpcInteraction; @@ -102,6 +104,12 @@ static function (ViewableConversation|Conversation $conversation) { 'wcf.conversation.hideConversation.leavePermanently.description', invalidatesAllItems: true ), + new EditInteraction( + ConversationDraftEditForm::class, + static function (ViewableConversation|Conversation $conversation) { + return $conversation->isDraft; + } + ), ]); EventHandler::getInstance()->fire( diff --git a/language/de.xml b/language/de.xml index bf0a1622..797e4e9f 100644 --- a/language/de.xml +++ b/language/de.xml @@ -159,6 +159,7 @@ + diff --git a/language/en.xml b/language/en.xml index da8be996..24c6e6d0 100644 --- a/language/en.xml +++ b/language/en.xml @@ -159,6 +159,7 @@ + diff --git a/templates/conversation.tpl b/templates/conversation.tpl index e1161fde..a0159136 100644 --- a/templates/conversation.tpl +++ b/templates/conversation.tpl @@ -5,44 +5,8 @@

          {@$conversation->getUserProfile()->getAvatar()->getImageTag(64)}
          - -
          -

          {$conversation->subject}

          - - -
          + + {include file='conversationContentHeaderTitle'} {hascontent}