From e6158c66405b37a864b9535214bca42a9cf64d71 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Mon, 14 Jul 2025 15:56:17 +0200 Subject: [PATCH 01/15] Redesign the conversation list --- .../{Ui => Component}/MarkAllAsRead.js | 19 +- .../Core/Conversation/Component/MarkAsRead.js | 26 ++ .../Core/Conversation/Ui/MarkAsRead.js | 35 --- .../data/conversation/Conversation.class.php | 17 ++ .../ViewableConversation.class.php | 4 +- .../message/ConversationMessage.class.php | 33 +++ .../user/ConversationListView.class.php | 3 +- files/style/conversation.scss | 256 +++++++++++++++--- language/de.xml | 6 +- language/en.xml | 6 +- templates/conversationList.tpl | 6 +- templates/conversationListItems.tpl | 216 ++++++++------- .../{Ui => Component}/MarkAllAsRead.ts | 22 +- .../Core/Conversation/Component/MarkAsRead.ts | 34 +++ .../Core/Conversation/Ui/MarkAsRead.ts | 43 --- 15 files changed, 483 insertions(+), 243 deletions(-) rename files/js/WoltLabSuite/Core/Conversation/{Ui => Component}/MarkAllAsRead.js (60%) create mode 100644 files/js/WoltLabSuite/Core/Conversation/Component/MarkAsRead.js delete mode 100644 files/js/WoltLabSuite/Core/Conversation/Ui/MarkAsRead.js rename ts/WoltLabSuite/Core/Conversation/{Ui => Component}/MarkAllAsRead.ts (56%) create mode 100644 ts/WoltLabSuite/Core/Conversation/Component/MarkAsRead.ts delete mode 100644 ts/WoltLabSuite/Core/Conversation/Ui/MarkAsRead.ts 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/lib/data/conversation/Conversation.class.php b/files/lib/data/conversation/Conversation.class.php index 75ce0b41..b7ca6c30 100644 --- a/files/lib/data/conversation/Conversation.class.php +++ b/files/lib/data/conversation/Conversation.class.php @@ -13,6 +13,7 @@ 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; @@ -150,6 +151,22 @@ public function setLastMessage(?int $userID, string $username, int $time) $this->data['lastPoster'] = $username; } + /** + * @since 6.2 + */ + public function getTeaser(): string + { + return $this->getFirstMessage()->getTeaser(); + } + + /** + * @since 6.2 + */ + public function getImage(): ?ImageData + { + return $this->getFirstMessage()->getImage(); + } + /** * Loads participation data for given user id (default: current user) on runtime. * You should use Conversation::getUserConversation() instead if possible. diff --git a/files/lib/data/conversation/ViewableConversation.class.php b/files/lib/data/conversation/ViewableConversation.class.php index 424322ec..7ee465fd 100644 --- a/files/lib/data/conversation/ViewableConversation.class.php +++ b/files/lib/data/conversation/ViewableConversation.class.php @@ -128,11 +128,13 @@ public function getParticipantSummary() $data = \unserialize($this->participantSummary); if ($data !== false) { foreach ($data as $userData) { + $this->__participantSummary[] = UserProfileRuntimeCache::getInstance()->getObject($userData['userID']); + /* todo $this->__participantSummary[] = new User(null, [ 'userID' => $userData['userID'], 'username' => $userData['username'], 'hideConversation' => $userData['hideConversation'], - ]); + ]);*/ } } } diff --git a/files/lib/data/conversation/message/ConversationMessage.class.php b/files/lib/data/conversation/message/ConversationMessage.class.php index 214fdfcc..e2d30cda 100644 --- a/files/lib/data/conversation/message/ConversationMessage.class.php +++ b/files/lib/data/conversation/message/ConversationMessage.class.php @@ -9,6 +9,7 @@ use wcf\data\IMessage; use wcf\data\object\type\ObjectTypeCache; use wcf\data\TUserContent; +use wcf\system\file\processor\ImageData; use wcf\system\html\output\HtmlOutputProcessor; use wcf\system\message\embedded\object\MessageEmbeddedObjectManager; use wcf\system\request\LinkHandler; @@ -130,6 +131,38 @@ public function getMailText(string $mimeType = 'text/plain'): string throw new \LogicException('Unreachable'); } + /** + * @since 6.2 + */ + public function getTeaser(): string + { + $processor = new HtmlOutputProcessor(); + $processor->setOutputType('text/plain'); + $processor->process($this->message, 'com.woltlab.wcf.conversation.message', $this->messageID); + + return StringUtil::truncate($processor->getHtml(), 255); + } + + /** + * @since 6.2 + */ + public function getImage(): ?ImageData + { + // todo + $list = $this->getAttachments(); + if ($list === null) { + return null; + } + + foreach ($list->getGroupedObjects($this->messageID) as $attachment) { + if ($attachment->isImage) { + return $attachment->getFile()->getImageData(320, 200); + } + } + + return null; + } + /** * Returns the conversation of this message. * diff --git a/files/lib/system/listView/user/ConversationListView.class.php b/files/lib/system/listView/user/ConversationListView.class.php index 5a17b2b3..ec258e29 100644 --- a/files/lib/system/listView/user/ConversationListView.class.php +++ b/files/lib/system/listView/user/ConversationListView.class.php @@ -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("conversationList"); + $this->setContainerCssClassName('conversationList__container'); } #[\Override] diff --git a/files/style/conversation.scss b/files/style/conversation.scss index bcf0ef87..a3a621be 100644 --- a/files/style/conversation.scss +++ b/files/style/conversation.scss @@ -25,47 +25,241 @@ width: 100%; } -.conversationItem { - .conversationInfo { - display: flex; +.conversationList { + display: flex; + flex-direction: column; + gap: 20px; +} + +.conversationList__item { + background-color: var(--wcfContentBackground); + border-radius: var(--wcfBorderRadius); + box-shadow: var(--wcfBoxShadowCard); + display: flex; + flex-direction: column; + gap: 10px; + padding: 20px; + position: relative; +} + +html[data-color-scheme="dark"] { + .conversationList__item { + border: 1px solid var(--wcfContentBorderInner); } +} + +.conversationList__item__header { + align-items: center; + display: flex; + gap: 10px; + justify-content: space-between; +} + +.conversationList__item__meta { + display: flex; + gap: 10px; - .conversationParticipant { - flex: 1 1 auto; + color: var(--wcfContentDimmedText); + + @include wcfFontSmall; + + .icon { + color: inherit; } - .conversationLastPostTime { - flex: 0 0 auto; - margin-left: 10px; +} + +.conversationList__item__meta__content { + display: flex; + flex-direction: column; +} + +.conversationList__item:not(:hover) .conversationList__item__toolbar { + grid-template-columns: min-content fit-content(0); + column-gap: 0; +} + +.conversationList__item__toolbar { + align-items: center; + display: grid; + column-gap: 5px; + grid-template-columns: auto fit-content(200px); + transition: + grid-template-columns 0.24s ease-in-out 0.12s, + column-gap 0.24s ease-in-out 0.12s; + z-index: 1; +} + +.conversationList__item:not(:hover) .conversationList__item__interactions { + opacity: 0; +} + +.conversationList__item__status { + display: flex; + gap: 5px; +} + +.conversationList__item__interactions { + display: flex; + gap: 5px; + opacity: 1; + transition: opacity 0.24s ease-in-out 0.12s; +} + +/* todo: handle touch */ +.conversationList__item:hover .conversationList__item__interactions { + display: flex; +} + +.conversationList__item__interactions .dropdown { + display: flex; +} + +.conversationList__item__content { + display: grid; + grid-template-areas: + "markAsRead title image" + "labels labels image" + "teaser teaser image"; + grid-template-columns: min-content 1fr min-content; + grid-template-rows: min-content min-content 1fr; + row-gap: 5px; +} + +.conversationList__item__markAsRead { + grid-area: markAsRead; + z-index: 1; + display: flex; + padding: 0 5px; + margin-right: 5px; + align-items: center; +} + +.conversationList__item__unread__indicator { + background-color: var(--wcfButtonPrimaryBackground); + border-radius: 50%; + display: inline-block; + height: 10px; + width: 10px; +} + +.conversationList__item__markAsRead:hover .conversationList__item__unread__indicator { + background-color: var(--wcfButtonPrimaryBackgroundActive); +} + +.conversationList__item__title { + color: var(--wcfContentHeadlineLink); + grid-area: title; + + @include wcfFontHeadline; + @include wcfFontBold; + + &:hover { + color: var(--wcfContentHeadlineLinkActive); } } -.conversationList { - .columnInteractions { - display: flex; - flex-direction: column; +.conversationList__item__labels { + grid-area: labels; +} + +.conversationList__item__teaser { + display: -webkit-box; + grid-area: teaser; + overflow: hidden; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.conversationList__item__image { + grid-area: image; + width: 160px; + overflow: hidden; + border-radius: var(--wcfBorderRadius); +} + +.conversationList__item__image img { + height: 100px; + object-fit: cover; + object-position: center center; + width: 100%; +} + +.conversationList__item__link { + color: inherit; + + &::before { + content: ""; + inset: 0; + position: absolute; } - @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; - } + &:hover, + &:focus { + color: inherit; + text-decoration: underline; + text-underline-offset: 0.2em; } +} - /* revert style from `messageGroup.scss` */ - @include screen-md-up { - &.messageGroupList .pagination { - font-size: inherit; - } +.conversationList__item__footer { + align-items: center; + display: flex; + gap: 10px; + color: var(--wcfContentDimmedText); + + @include wcfFontSmall; + + .icon { + color: inherit; } - .pagination { - flex: inherit; - opacity: inherit; - transition: inherit; - font-weight: inherit; +} + +.conversationList__item__lastPost { + margin-inline-start: auto; + display: flex; + gap: 10px; + position: relative; +} + +.conversationList__item__lastPost__link { + z-index: 1; + color: inherit; + + &::before { + content: ""; + inset: 0; + position: absolute; + } + + &:hover, + &:focus { + color: inherit; + text-decoration: underline; + text-underline-offset: 0.2em; } } + +.conversationList__item__participants { + display: flex; + flex-direction: row-reverse; + margin-left: -10px; + margin-right: 25px; + align-items: center; +} + +.conversationList__item__participant { + transform: translateX(10px); + width: 16px; +} + +.conversationList__item__otherParticipant { + transform: translateX(20px); +} + +.conversationList__item__replies, +.conversationList__item__lastPost__time, +.conversationList__item__lastPost__author { + align-items: center; + display: flex; + gap: 3px; +} diff --git a/language/de.xml b/language/de.xml index 7f2cd2ff..95f0b431 100644 --- a/language/de.xml +++ b/language/de.xml @@ -120,21 +120,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}.]]> - + diff --git a/language/en.xml b/language/en.xml index dd76892b..c1ff3f5e 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}.]]> - + 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()}