diff --git a/com.woltlab.wcf/templates/moderationList.tpl b/com.woltlab.wcf/templates/moderationList.tpl
index fa00b83dccc..5f288af7a4d 100644
--- a/com.woltlab.wcf/templates/moderationList.tpl
+++ b/com.woltlab.wcf/templates/moderationList.tpl
@@ -6,7 +6,21 @@
{/capture}
{capture assign='contentInteractionButtons'}
-
+ {if $gridView->canMarkAsRead()}
+
+
+
+ {/if}
{icon name='trash-can'} {lang}wcf.moderation.showDeletedContent{/lang}
{/capture}
@@ -16,10 +30,4 @@
{unsafe:$gridView->render()}
-
-
{include file='footer'}
diff --git a/ts/WoltLabSuite/Core/Component/GridView.ts b/ts/WoltLabSuite/Core/Component/GridView.ts
index ddfa7b50023..78338c938f9 100644
--- a/ts/WoltLabSuite/Core/Component/GridView.ts
+++ b/ts/WoltLabSuite/Core/Component/GridView.ts
@@ -10,6 +10,7 @@
import { getRow } from "../Api/GridViews/GetRow";
import { getRows } from "../Api/GridViews/GetRows";
import { getBulkContextMenuOptions } from "../Api/Interactions/GetBulkContextMenuOptions";
+import { postObject } from "../Api/PostObject";
import DomChangeListener from "../Dom/Change/Listener";
import DomUtil from "../Dom/Util";
import { promiseMutex } from "../Helper/PromiseMutex";
@@ -104,6 +105,16 @@ export class GridView {
});
}
});
+
+ wheneverFirstSeen(`#${this.#table.id} tbody tr .gridView__row__markAsRead`, (button: HTMLButtonElement) => {
+ button.addEventListener(
+ "click",
+ promiseMutex(async () => {
+ await postObject(button.dataset.endpoint!);
+ button.closest("tr")?.dispatchEvent(new CustomEvent("interaction:invalidate", { bubbles: true }));
+ }),
+ );
+ });
}
#initEventListeners(): void {
diff --git a/ts/WoltLabSuite/Core/Component/Moderation/MarkAllModerationQueuesAsRead.ts b/ts/WoltLabSuite/Core/Component/Moderation/MarkAllModerationQueuesAsRead.ts
new file mode 100644
index 00000000000..ff24b274294
--- /dev/null
+++ b/ts/WoltLabSuite/Core/Component/Moderation/MarkAllModerationQueuesAsRead.ts
@@ -0,0 +1,34 @@
+/**
+ * Handles the 'mark as read' action for moderation queues.
+ *
+ * @author Marcel Werk
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ * @since 6.3
+ * @woltlabExcludeBundle tiny
+ */
+
+import { showDefaultSuccessSnackbar } from "WoltLabSuite/Core/Component/Snackbar";
+import { markAllModerationQueuesAsRead } from "WoltLabSuite/Core/Api/ModerationQueues/MarkAllModerationQueuesAsRead";
+import { promiseMutex } from "WoltLabSuite/Core/Helper/PromiseMutex";
+
+async function markAllAsRead(button: HTMLElement, gridView?: HTMLElement): Promise {
+ (await markAllModerationQueuesAsRead()).unwrap();
+
+ if (gridView !== undefined) {
+ gridView.dispatchEvent(new CustomEvent("interaction:invalidate-all"));
+ }
+
+ button.remove();
+
+ showDefaultSuccessSnackbar();
+}
+
+export function setup(button: HTMLElement, gridView?: HTMLElement): void {
+ button.addEventListener(
+ "click",
+ promiseMutex(async () => {
+ await markAllAsRead(button, gridView);
+ }),
+ );
+}
diff --git a/ts/WoltLabSuite/Core/Ui/Moderation/MarkAllAsRead.ts b/ts/WoltLabSuite/Core/Ui/Moderation/MarkAllAsRead.ts
deleted file mode 100644
index c6cec9f972e..00000000000
--- a/ts/WoltLabSuite/Core/Ui/Moderation/MarkAllAsRead.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-/**
- * Marks all moderation queues as read.
- *
- * @author Marcel Werk
- * @copyright 2001-2022 WoltLab GmbH
- * @license GNU Lesser General Public License
- * @since 6.0
- */
-
-import { showDefaultSuccessSnackbar } from "WoltLabSuite/Core/Component/Snackbar";
-import { markAllModerationQueuesAsRead } from "WoltLabSuite/Core/Api/ModerationQueues/MarkAllModerationQueuesAsRead";
-
-async function markAllAsRead(): Promise {
- (await markAllModerationQueuesAsRead()).unwrap();
-
- const gridViewTable = document.getElementById("wcf-system-gridView-user-ModerationQueueGridView_table")!;
- gridViewTable.dispatchEvent(new CustomEvent("interaction:invalidate-all"));
-
- showDefaultSuccessSnackbar();
-}
-
-export function setup(): void {
- document.querySelector(".markAllAsReadButton")?.addEventListener("click", () => {
- void markAllAsRead();
- });
-}
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView.js
index 968ef2424a8..8769b823181 100644
--- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView.js
+++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView.js
@@ -6,7 +6,7 @@
* @license GNU Lesser General Public License
* @since 6.2
*/
-define(["require", "exports", "tslib", "../Api/GridViews/GetRow", "../Api/GridViews/GetRows", "../Api/Interactions/GetBulkContextMenuOptions", "../Dom/Change/Listener", "../Dom/Util", "../Helper/PromiseMutex", "../Helper/Selector", "../Ui/Dropdown/Simple", "./GridView/State"], function (require, exports, tslib_1, GetRow_1, GetRows_1, GetBulkContextMenuOptions_1, Listener_1, Util_1, PromiseMutex_1, Selector_1, Simple_1, State_1) {
+define(["require", "exports", "tslib", "../Api/GridViews/GetRow", "../Api/GridViews/GetRows", "../Api/Interactions/GetBulkContextMenuOptions", "../Api/PostObject", "../Dom/Change/Listener", "../Dom/Util", "../Helper/PromiseMutex", "../Helper/Selector", "../Ui/Dropdown/Simple", "./GridView/State"], function (require, exports, tslib_1, GetRow_1, GetRows_1, GetBulkContextMenuOptions_1, PostObject_1, Listener_1, Util_1, PromiseMutex_1, Selector_1, Simple_1, State_1) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.GridView = void 0;
@@ -65,6 +65,12 @@ define(["require", "exports", "tslib", "../Api/GridViews/GetRow", "../Api/GridVi
});
}
});
+ (0, Selector_1.wheneverFirstSeen)(`#${this.#table.id} tbody tr .gridView__row__markAsRead`, (button) => {
+ button.addEventListener("click", (0, PromiseMutex_1.promiseMutex)(async () => {
+ await (0, PostObject_1.postObject)(button.dataset.endpoint);
+ button.closest("tr")?.dispatchEvent(new CustomEvent("interaction:invalidate", { bubbles: true }));
+ }));
+ });
}
#initEventListeners() {
this.#table.addEventListener("interaction:invalidate-all", () => {
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Moderation/MarkAllModerationQueuesAsRead.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Moderation/MarkAllModerationQueuesAsRead.js
new file mode 100644
index 00000000000..791f888ce86
--- /dev/null
+++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Moderation/MarkAllModerationQueuesAsRead.js
@@ -0,0 +1,27 @@
+/**
+ * Handles the 'mark as read' action for moderation queues.
+ *
+ * @author Marcel Werk
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ * @since 6.3
+ * @woltlabExcludeBundle tiny
+ */
+define(["require", "exports", "WoltLabSuite/Core/Component/Snackbar", "WoltLabSuite/Core/Api/ModerationQueues/MarkAllModerationQueuesAsRead", "WoltLabSuite/Core/Helper/PromiseMutex"], function (require, exports, Snackbar_1, MarkAllModerationQueuesAsRead_1, PromiseMutex_1) {
+ "use strict";
+ Object.defineProperty(exports, "__esModule", { value: true });
+ exports.setup = setup;
+ async function markAllAsRead(button, gridView) {
+ (await (0, MarkAllModerationQueuesAsRead_1.markAllModerationQueuesAsRead)()).unwrap();
+ if (gridView !== undefined) {
+ gridView.dispatchEvent(new CustomEvent("interaction:invalidate-all"));
+ }
+ button.remove();
+ (0, Snackbar_1.showDefaultSuccessSnackbar)();
+ }
+ function setup(button, gridView) {
+ button.addEventListener("click", (0, PromiseMutex_1.promiseMutex)(async () => {
+ await markAllAsRead(button, gridView);
+ }));
+ }
+});
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Moderation/MarkAllAsRead.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Moderation/MarkAllAsRead.js
deleted file mode 100644
index fb1c4e11253..00000000000
--- a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Moderation/MarkAllAsRead.js
+++ /dev/null
@@ -1,24 +0,0 @@
-/**
- * Marks all moderation queues as read.
- *
- * @author Marcel Werk
- * @copyright 2001-2022 WoltLab GmbH
- * @license GNU Lesser General Public License
- * @since 6.0
- */
-define(["require", "exports", "WoltLabSuite/Core/Component/Snackbar", "WoltLabSuite/Core/Api/ModerationQueues/MarkAllModerationQueuesAsRead"], function (require, exports, Snackbar_1, MarkAllModerationQueuesAsRead_1) {
- "use strict";
- Object.defineProperty(exports, "__esModule", { value: true });
- exports.setup = setup;
- async function markAllAsRead() {
- (await (0, MarkAllModerationQueuesAsRead_1.markAllModerationQueuesAsRead)()).unwrap();
- const gridViewTable = document.getElementById("wcf-system-gridView-user-ModerationQueueGridView_table");
- gridViewTable.dispatchEvent(new CustomEvent("interaction:invalidate-all"));
- (0, Snackbar_1.showDefaultSuccessSnackbar)();
- }
- function setup() {
- document.querySelector(".markAllAsReadButton")?.addEventListener("click", () => {
- void markAllAsRead();
- });
- }
-});
diff --git a/wcfsetup/install/files/lib/system/gridView/AbstractGridView.class.php b/wcfsetup/install/files/lib/system/gridView/AbstractGridView.class.php
index 0ff51e4f30f..ef4ef47dc21 100644
--- a/wcfsetup/install/files/lib/system/gridView/AbstractGridView.class.php
+++ b/wcfsetup/install/files/lib/system/gridView/AbstractGridView.class.php
@@ -2,6 +2,7 @@
namespace wcf\system\gridView;
+use wcf\action\ApiAction;
use wcf\action\GridViewFilterAction;
use wcf\data\DatabaseObject;
use wcf\data\DatabaseObjectList;
@@ -64,6 +65,7 @@ abstract class AbstractGridView
private ?IInteractionProvider $interactionProvider = null;
private ?IBulkInteractionProvider $bulkInteractionProvider = null;
private InteractionContextMenuComponent $interactionContextMenuComponent;
+ private string $markAsReadEndpoint = '';
/**
* @var array
@@ -313,6 +315,10 @@ public function renderColumn(GridViewColumn $column, DatabaseObject $row): strin
$value = $this->rowLink->render($value, $row, $column->isTitleColumn());
}
+ if ($column->hasMarkAsReadButton() && $this->rowIsNew($row)) {
+ $value = $this->renderMarkAsReadButton($row) . $value;
+ }
+
return $value;
}
@@ -952,6 +958,49 @@ public function getAvailableFilters(): array
return $this->availableFilters;
}
+ /**
+ * @since 6.3
+ */
+ public function setMarkAsReadEndpoint(string $endpoint): void
+ {
+ $this->markAsReadEndpoint = $endpoint;
+ }
+
+ /**
+ * @since 6.3
+ */
+ public function renderMarkAsReadButton(DatabaseObject $object): string
+ {
+ if (!$this->markAsReadEndpoint) {
+ throw new \BadMethodCallException("No mark as read endpoint has been specified.");
+ }
+
+ $endpoint = StringUtil::encodeHTML(
+ LinkHandler::getInstance()->getControllerLink(ApiAction::class, ['id' => 'rpc']) .
+ \sprintf($this->markAsReadEndpoint, $object->getObjectID())
+ );
+ $title = WCF::getLanguage()->get('wcf.global.button.markAsRead');
+
+ return <<
+
+
+ HTML;
+ }
+
+ /**
+ * @since 6.3
+ */
+ public function rowIsNew(DatabaseObject $row): bool
+ {
+ return false;
+ }
+
private function init(): void
{
if (!isset($this->objectList)) {
diff --git a/wcfsetup/install/files/lib/system/gridView/GridViewColumn.class.php b/wcfsetup/install/files/lib/system/gridView/GridViewColumn.class.php
index 1bb43a674cf..e67eae867ff 100644
--- a/wcfsetup/install/files/lib/system/gridView/GridViewColumn.class.php
+++ b/wcfsetup/install/files/lib/system/gridView/GridViewColumn.class.php
@@ -35,6 +35,7 @@ final class GridViewColumn
private bool $hidden = false;
private bool $unsafeDisableEncoding = false;
private bool $titleColumn = false;
+ private bool $markAsReadButton = false;
private function __construct(private readonly string $id) {}
@@ -302,6 +303,28 @@ public function applyRowLink(): bool
)) === 0;
}
+ /**
+ * Sets whether the mark as read button is rendered in this column for unread rows.
+ *
+ * @since 6.3
+ */
+ public function markAsReadButton(bool $markAsReadButton = true): static
+ {
+ $this->markAsReadButton = $markAsReadButton;
+
+ return $this;
+ }
+
+ /**
+ * Returns true if the mark as read button is rendered in this column for unread rows.
+ *
+ * @since 6.3
+ */
+ public function hasMarkAsReadButton(): bool
+ {
+ return $this->markAsReadButton;
+ }
+
/**
* Returns the default renderer for the rendering of columns.
*/
diff --git a/wcfsetup/install/files/lib/system/gridView/user/ModerationQueueGridView.class.php b/wcfsetup/install/files/lib/system/gridView/user/ModerationQueueGridView.class.php
index 956e16ffdda..1880f3b0deb 100644
--- a/wcfsetup/install/files/lib/system/gridView/user/ModerationQueueGridView.class.php
+++ b/wcfsetup/install/files/lib/system/gridView/user/ModerationQueueGridView.class.php
@@ -44,26 +44,15 @@ public function __construct()
GridViewColumn::for('title')
->label('wcf.global.title')
->titleColumn()
+ ->markAsReadButton()
->renderer(
new class extends DefaultColumnRenderer {
#[\Override]
public function render(mixed $value, DatabaseObject $row): string
{
\assert($row instanceof ViewableModerationQueue);
- $title = StringUtil::encodeHTML($row->getTitle());
- if ($row->isNew()) {
- $badgeLabel = WCF::getLanguage()->get('wcf.message.new');
- $badge = <<{$badgeLabel}
- HTML;
- } else {
- $badge = '';
- }
-
- return <<getTitle());
}
}
),
@@ -216,6 +205,16 @@ public function render(mixed $value, DatabaseObject $row): string
$this->setDefaultSortField("lastChangeTime");
$this->setDefaultSortOrder("DESC");
$this->addRowLink(new GridViewRowLink(isLinkableObject: true));
+ $this->setMarkAsReadEndpoint('core/moderation-queues/%s/mark-as-read');
+ }
+
+ public function canMarkAsRead(): bool
+ {
+ if (!WCF::getUser()->userID) {
+ return false;
+ }
+
+ return ModerationQueueManager::getInstance()->getUnreadModerationCount() > 0;
}
private function getDefinitionFilter(): SelectFilter
@@ -275,4 +274,12 @@ protected function getInitializedEvent(): ModerationQueueGridViewInitialized
{
return new ModerationQueueGridViewInitialized($this);
}
+
+ #[\Override]
+ public function rowIsNew(DatabaseObject $row): bool
+ {
+ \assert($row instanceof ViewableModerationQueue);
+
+ return $row->isNew();
+ }
}
diff --git a/wcfsetup/install/files/lib/system/interaction/user/ModerationQueueInteractions.class.php b/wcfsetup/install/files/lib/system/interaction/user/ModerationQueueInteractions.class.php
index cb8d52bd4a4..c8fe5c778d2 100644
--- a/wcfsetup/install/files/lib/system/interaction/user/ModerationQueueInteractions.class.php
+++ b/wcfsetup/install/files/lib/system/interaction/user/ModerationQueueInteractions.class.php
@@ -30,14 +30,6 @@ final class ModerationQueueInteractions extends AbstractInteractionProvider
public function __construct()
{
$this->addInteractions([
- new RpcInteraction(
- "mark-as-read",
- "core/moderation-queues/%s/mark-as-read",
- "wcf.global.button.markAsRead",
- isAvailableCallback: static function (ViewableModerationQueue $queue) {
- return $queue->isNew();
- }
- ),
new FormBuilderDialogInteraction(
"assign-user",
LinkHandler::getInstance()->getControllerLink(ModerationQueueAssignUserAction::class, ["id" => "%s"]),
diff --git a/wcfsetup/install/files/style/ui/gridView.scss b/wcfsetup/install/files/style/ui/gridView.scss
index 5a3be1c6513..c3962439a1d 100644
--- a/wcfsetup/install/files/style/ui/gridView.scss
+++ b/wcfsetup/install/files/style/ui/gridView.scss
@@ -117,6 +117,30 @@
}
}
+.gridView__row__markAsRead {
+ align-items: center;
+ display: inline-flex;
+ margin-right: 5px;
+ padding: 0 5px;
+ position: relative;
+ vertical-align: middle;
+ z-index: 1;
+}
+
+.gridView__row__unread__indicator {
+ background-color: var(--wcfButtonPrimaryBackground);
+ border-radius: 50%;
+ display: inline-block;
+ height: 10px;
+ width: 10px;
+}
+
+@media (hover: hover) {
+ .gridView__row__markAsRead:hover .gridView__row__unread__indicator {
+ background-color: var(--wcfButtonPrimaryBackgroundActive);
+ }
+}
+
.gridView__headerColumn {
background-color: var(--wcfContentContainerBackground);
box-shadow: inset 0 -1px 0 0 var(--wcfContentBorder);