Add new endpoint of render the full quote of a message
authorCyperghost <olaf_schmitz_1@t-online.de>
Thu, 19 Dec 2024 10:13:51 +0000 (11:13 +0100)
committerAlexander Ebert <ebert@woltlab.com>
Wed, 8 Jan 2025 16:25:19 +0000 (17:25 +0100)
ts/WoltLabSuite/Core/Api/Messages/RenderQuote.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Component/Quote/List.ts
ts/WoltLabSuite/Core/Component/Quote/Message.ts
ts/WoltLabSuite/Core/Component/Quote/Storage.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Message/Quote.ts
wcfsetup/install/files/js/WoltLabSuite/Core/Component/Quote/List.js
wcfsetup/install/files/js/WoltLabSuite/Core/Component/Quote/Message.js
wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Message/Quote.js
wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php
wcfsetup/install/files/lib/system/endpoint/controller/core/messages/RenderQuote.class.php [new file with mode: 0644]

diff --git a/ts/WoltLabSuite/Core/Api/Messages/RenderQuote.ts b/ts/WoltLabSuite/Core/Api/Messages/RenderQuote.ts
new file mode 100644 (file)
index 0000000..8f389ff
--- /dev/null
@@ -0,0 +1,29 @@
+/**
+ * Requests render a full quote of a message.
+ *
+ * @author Olaf Braun
+ * @copyright 2001-2024 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since 6.2
+ * @woltlabExcludeBundle tiny
+ */
+
+import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend";
+import { ApiResult, apiResultFromError, apiResultFromValue } from "../Result";
+
+type Response = string;
+
+export async function renderQuote(objectType: string, objectID: number): Promise<ApiResult<Response>> {
+  const url = new URL(window.WSC_RPC_API_URL + "core/messages/renderquote");
+  url.searchParams.set("objectType", objectType);
+  url.searchParams.set("objectID", objectID.toString());
+
+  let response: Response;
+  try {
+    response = (await prepareRequest(url).get().fetchAsJson()) as Response;
+  } catch (e) {
+    return apiResultFromError(e);
+  }
+
+  return apiResultFromValue(response);
+}
index 0768878da38ef3f61d8a4db34c7c6a71aa34aa50..b047baacf7b33ec93e838a4080d76479b033ffc7 100644 (file)
@@ -8,14 +8,13 @@
  * @woltlabExcludeBundle tiny
  */
 
-import * as Core from "WoltLabSuite/Core/Core";
 import { listenToCkeditor } from "WoltLabSuite/Core/Component/Ckeditor/Event";
 import type { CKEditor } from "WoltLabSuite/Core/Component/Ckeditor";
 import { getTabMenu } from "WoltLabSuite/Core/Component/Message/MessageTabMenu";
 import { getPhrase } from "WoltLabSuite/Core/Language";
 import { setActiveEditor } from "WoltLabSuite/Core/Component/Quote/Message";
+import { getQuotes } from "WoltLabSuite/Core/Component/Quote/Storage";
 
-export const STORAGE_KEY = Core.getStoragePrefix() + "quotes";
 const quoteLists = new Map<string, QuoteList>();
 
 class QuoteList {
@@ -39,7 +38,11 @@ class QuoteList {
   }
 
   public renderQuotes(): void {
-    this.#container.innerHTML = window.localStorage.getItem(STORAGE_KEY) || "";
+    this.#container.innerHTML = "";
+
+    for (const [, quotes] of getQuotes()) {
+      // TODO render quotes
+    }
 
     if (this.#container.hasChildNodes()) {
       getTabMenu(this.#editorId)?.showTab(
index a8c26650572270b4a316fb95a540857a341f8895..b0297ab22b4ac395532753268af015c13cec61f4 100644 (file)
@@ -13,27 +13,38 @@ import { getPhrase } from "WoltLabSuite/Core/Language";
 import { wheneverFirstSeen } from "WoltLabSuite/Core/Helper/Selector";
 import { set as setAlignment } from "WoltLabSuite/Core/Ui/Alignment";
 import { CKEditor } from "WoltLabSuite/Core/Component/Ckeditor";
+import { saveQuote, saveFullQuote } from "WoltLabSuite/Core/Component/Quote/Storage";
+import { promiseMutex } from "WoltLabSuite/Core/Helper/PromiseMutex";
 
 interface Container {
   element: HTMLElement;
   messageBodySelector: string;
+  objectType: string;
+  objectId: number;
 }
 
+let selectedMessage:
+  | undefined
+  | {
+      message: string;
+      container: Container;
+    };
+
 const containers = new Map<string, Container>();
 let activeMessageId = "";
-let message = "";
 let activeEditor: CKEditor | undefined = undefined;
 let timerSelectionChange: number | undefined = undefined;
 let isMouseDown = false;
-let objectId: number | undefined = undefined;
 const copyQuote = document.createElement("div");
 
-export function registerContainer(containerSelector: string, messageBodySelector: string): void {
+export function registerContainer(containerSelector: string, messageBodySelector: string, objectType: string): void {
   wheneverFirstSeen(containerSelector, (container: HTMLElement) => {
     const id = DomUtil.identify(container);
     containers.set(id, {
       element: container,
       messageBodySelector: messageBodySelector,
+      objectType: objectType,
+      objectId: ~~container.dataset.objectId!,
     });
 
     if (container.classList.contains("jsInvalidQuoteTarget")) {
@@ -43,13 +54,19 @@ export function registerContainer(containerSelector: string, messageBodySelector
     container.addEventListener("mousedown", (event) => onMouseDown(event));
     container.classList.add("jsQuoteMessageContainer");
 
-    container.querySelector(".jsQuoteMessage")?.addEventListener("click", () => {
-      //TODO
-    });
+    container.querySelector(".jsQuoteMessage")?.addEventListener(
+      "click",
+      promiseMutex(async (event: MouseEvent) => {
+        event.preventDefault();
+
+        await saveFullQuote(objectType, ~~container.dataset.objectId!);
+        //TODO insert into `activeEditor`
+      }),
+    );
   });
 }
 
-export function setActiveEditor(editor: CKEditor, supportDirectInsert: boolean) {
+export function setActiveEditor(editor?: CKEditor, supportDirectInsert: boolean = false) {
   copyQuote.querySelector<HTMLButtonElement>(".jsQuoteManagerQuoteAndInsert")!.hidden = !supportDirectInsert;
 
   activeEditor = editor;
@@ -63,7 +80,9 @@ function setup() {
   buttonSaveQuote.classList.add("jsQuoteManagerStore");
   buttonSaveQuote.textContent = getPhrase("wcf.message.quote.quoteSelected");
   buttonSaveQuote.addEventListener("click", () => {
-    //TODO
+    saveQuote(selectedMessage!.container.objectType, selectedMessage!.container.objectId, selectedMessage!.message);
+
+    removeSelection();
   });
   copyQuote.appendChild(buttonSaveQuote);
   const buttonSaveAndInsertQuote = document.createElement("button");
@@ -72,7 +91,10 @@ function setup() {
   buttonSaveAndInsertQuote.classList.add("jsQuoteManagerQuoteAndInsert");
   buttonSaveAndInsertQuote.textContent = getPhrase("wcf.message.quote.quoteAndReply");
   buttonSaveAndInsertQuote.addEventListener("click", () => {
-    //TODO
+    saveQuote(selectedMessage!.container.objectType, selectedMessage!.container.objectId, selectedMessage!.message);
+    //TODO insert into `activeEditor`
+
+    removeSelection();
   });
   copyQuote.appendChild(buttonSaveAndInsertQuote);
 
@@ -386,8 +408,19 @@ function onMouseUp(event?: MouseEvent): void {
     const text = getSelectedText().trim();
     if (text !== "") {
       copyQuote.classList.add("active");
-      message = text;
-      objectId = ~~container.element.dataset.objectId!;
+      selectedMessage = {
+        message: text,
+        container: container,
+      };
     }
   }, 10);
 }
+
+function removeSelection(): void {
+  copyQuote.classList.remove("active");
+
+  const selection = window.getSelection()!;
+  if (selection.rangeCount) {
+    selection.removeAllRanges();
+  }
+}
diff --git a/ts/WoltLabSuite/Core/Component/Quote/Storage.ts b/ts/WoltLabSuite/Core/Component/Quote/Storage.ts
new file mode 100644 (file)
index 0000000..61bd25b
--- /dev/null
@@ -0,0 +1,85 @@
+/**
+ * Stores the quote data.
+ *
+ * @author Olaf Braun
+ * @copyright 2001-2024 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since 6.2
+ * @woltlabExcludeBundle tiny
+ */
+
+import * as Core from "WoltLabSuite/Core/Core";
+import { renderQuote } from "WoltLabSuite/Core/Api/Messages/RenderQuote";
+
+interface StorageData {
+  quotes: Map<string, Set<string>>;
+}
+
+export const STORAGE_KEY = Core.getStoragePrefix() + "quotes";
+
+export function saveQuote(objectType: string, objectId: number, message: string) {
+  const storage = getStorage();
+
+  const key = getKey(objectType, objectId);
+  if (!storage.quotes.has(key)) {
+    storage.quotes.set(key, new Set());
+  }
+
+  storage.quotes.get(key)!.add(message);
+
+  saveStorage(storage);
+}
+
+export async function saveFullQuote(objectType: string, objectId: number) {
+  const result = await renderQuote(objectType, objectId);
+  if (!result.ok) {
+    // TODO error handling
+    return;
+  }
+
+  saveQuote(objectType, objectId, result.value);
+}
+
+export function getQuotes(): Map<string, Set<string>> {
+  return getStorage().quotes;
+}
+
+function getStorage(): StorageData {
+  const data = window.localStorage.getItem(STORAGE_KEY);
+  if (data === null) {
+    return {
+      quotes: new Map(),
+    };
+  } else {
+    return JSON.parse(data, (key, value) => {
+      if (key === "quotes") {
+        const result = new Map<string, Set<string>>(value);
+        for (const [key, setValue] of result) {
+          result.set(key, new Set(setValue));
+        }
+        return result;
+      }
+
+      return value;
+    });
+  }
+}
+
+function getKey(objectType: string, objectId: number): string {
+  return `${objectType}:${objectId}`;
+}
+
+function saveStorage(data: StorageData) {
+  window.localStorage.setItem(
+    STORAGE_KEY,
+    JSON.stringify(data, (key, value) => {
+      if (value instanceof Map) {
+        return Array.from(value.entries());
+      } else if (value instanceof Set) {
+        return Array.from(value);
+      }
+
+      return value;
+    }),
+  );
+}
index 8a2ee184586e712c7f50987187dbb2baf1b9ddec..29b39a52d1aa02c18eae9d82c45dac3cbfbed998 100644 (file)
@@ -25,7 +25,7 @@ export class UiMessageQuote {
     messageContentSelector: string,
     supportDirectInsert: boolean,
   ) {
-    registerContainer(containerSelector, messageBodySelector);
+    registerContainer(containerSelector, messageBodySelector, objectType);
   }
 }
 
index b3ff4a52a5766b01ba4cf62b2957269b5b9ca91d..a7a080d342547328bf643b802e577121e14703b0 100644 (file)
@@ -7,14 +7,11 @@
  * @since 6.2
  * @woltlabExcludeBundle tiny
  */
-define(["require", "exports", "tslib", "WoltLabSuite/Core/Core", "WoltLabSuite/Core/Component/Ckeditor/Event", "WoltLabSuite/Core/Component/Message/MessageTabMenu", "WoltLabSuite/Core/Language", "WoltLabSuite/Core/Component/Quote/Message"], function (require, exports, tslib_1, Core, Event_1, MessageTabMenu_1, Language_1, Message_1) {
+define(["require", "exports", "WoltLabSuite/Core/Component/Ckeditor/Event", "WoltLabSuite/Core/Component/Message/MessageTabMenu", "WoltLabSuite/Core/Language", "WoltLabSuite/Core/Component/Quote/Message", "WoltLabSuite/Core/Component/Quote/Storage"], function (require, exports, Event_1, MessageTabMenu_1, Language_1, Message_1, Storage_1) {
     "use strict";
     Object.defineProperty(exports, "__esModule", { value: true });
-    exports.STORAGE_KEY = void 0;
     exports.getQuoteList = getQuoteList;
     exports.setup = setup;
-    Core = tslib_1.__importStar(Core);
-    exports.STORAGE_KEY = Core.getStoragePrefix() + "quotes";
     const quoteLists = new Map();
     class QuoteList {
         #container;
@@ -33,7 +30,10 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Core", "WoltLabSuite/C
             this.renderQuotes();
         }
         renderQuotes() {
-            this.#container.innerHTML = window.localStorage.getItem(exports.STORAGE_KEY) || "";
+            this.#container.innerHTML = "";
+            for (const [, quotes] of (0, Storage_1.getQuotes)()) {
+                // TODO render quotes
+            }
             if (this.#container.hasChildNodes()) {
                 (0, MessageTabMenu_1.getTabMenu)(this.#editorId)?.showTab("quotes", (0, Language_1.getPhrase)("wcf.message.quote.showQuotes", {
                     count: this.#container.childElementCount,
@@ -60,8 +60,8 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Core", "WoltLabSuite/C
                 quoteLists.set(editorId, new QuoteList(editorId, ckeditor));
             }
             (0, Message_1.setActiveEditor)(ckeditor, ckeditor.features.quoteBlock);
-            ckeditor.focusTracker.on("change:isFocused", () => {
-                if (ckeditor.focusTracker.isFocused) {
+            ckeditor.focusTracker.on("change:isFocused", (_evt, _name, isFocused) => {
+                if (isFocused) {
                     (0, Message_1.setActiveEditor)(ckeditor, ckeditor.features.quoteBlock);
                 }
             });
index 3068f6aad6b8a9d3993062fcb3c9e5eb2da6b46d..e213a4cf2fde26c6283dac0bfe6ec473ad6ab87f 100644 (file)
@@ -7,38 +7,41 @@
  * @since 6.2
  * @woltlabExcludeBundle tiny
  */
-define(["require", "exports", "tslib", "WoltLabSuite/Core/Dom/Util", "WoltLabSuite/Core/Language", "WoltLabSuite/Core/Helper/Selector", "WoltLabSuite/Core/Ui/Alignment"], function (require, exports, tslib_1, Util_1, Language_1, Selector_1, Alignment_1) {
+define(["require", "exports", "tslib", "WoltLabSuite/Core/Dom/Util", "WoltLabSuite/Core/Language", "WoltLabSuite/Core/Helper/Selector", "WoltLabSuite/Core/Ui/Alignment", "WoltLabSuite/Core/Component/Quote/Storage", "WoltLabSuite/Core/Helper/PromiseMutex"], function (require, exports, tslib_1, Util_1, Language_1, Selector_1, Alignment_1, Storage_1, PromiseMutex_1) {
     "use strict";
     Object.defineProperty(exports, "__esModule", { value: true });
     exports.registerContainer = registerContainer;
     exports.setActiveEditor = setActiveEditor;
     Util_1 = tslib_1.__importDefault(Util_1);
+    let selectedMessage;
     const containers = new Map();
     let activeMessageId = "";
-    let message = "";
     let activeEditor = undefined;
     let timerSelectionChange = undefined;
     let isMouseDown = false;
-    let objectId = undefined;
     const copyQuote = document.createElement("div");
-    function registerContainer(containerSelector, messageBodySelector) {
+    function registerContainer(containerSelector, messageBodySelector, objectType) {
         (0, Selector_1.wheneverFirstSeen)(containerSelector, (container) => {
             const id = Util_1.default.identify(container);
             containers.set(id, {
                 element: container,
                 messageBodySelector: messageBodySelector,
+                objectType: objectType,
+                objectId: ~~container.dataset.objectId,
             });
             if (container.classList.contains("jsInvalidQuoteTarget")) {
                 return;
             }
             container.addEventListener("mousedown", (event) => onMouseDown(event));
             container.classList.add("jsQuoteMessageContainer");
-            container.querySelector(".jsQuoteMessage")?.addEventListener("click", () => {
-                //TODO
-            });
+            container.querySelector(".jsQuoteMessage")?.addEventListener("click", (0, PromiseMutex_1.promiseMutex)(async (event) => {
+                event.preventDefault();
+                await (0, Storage_1.saveFullQuote)(objectType, ~~container.dataset.objectId);
+                //TODO insert into `activeEditor`
+            }));
         });
     }
-    function setActiveEditor(editor, supportDirectInsert) {
+    function setActiveEditor(editor, supportDirectInsert = false) {
         copyQuote.querySelector(".jsQuoteManagerQuoteAndInsert").hidden = !supportDirectInsert;
         activeEditor = editor;
     }
@@ -49,7 +52,8 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Dom/Util", "WoltLabSui
         buttonSaveQuote.classList.add("jsQuoteManagerStore");
         buttonSaveQuote.textContent = (0, Language_1.getPhrase)("wcf.message.quote.quoteSelected");
         buttonSaveQuote.addEventListener("click", () => {
-            //TODO
+            (0, Storage_1.saveQuote)(selectedMessage.container.objectType, selectedMessage.container.objectId, selectedMessage.message);
+            removeSelection();
         });
         copyQuote.appendChild(buttonSaveQuote);
         const buttonSaveAndInsertQuote = document.createElement("button");
@@ -58,7 +62,9 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Dom/Util", "WoltLabSui
         buttonSaveAndInsertQuote.classList.add("jsQuoteManagerQuoteAndInsert");
         buttonSaveAndInsertQuote.textContent = (0, Language_1.getPhrase)("wcf.message.quote.quoteAndReply");
         buttonSaveAndInsertQuote.addEventListener("click", () => {
-            //TODO
+            (0, Storage_1.saveQuote)(selectedMessage.container.objectType, selectedMessage.container.objectId, selectedMessage.message);
+            //TODO insert into `activeEditor`
+            removeSelection();
         });
         copyQuote.appendChild(buttonSaveAndInsertQuote);
         document.body.appendChild(copyQuote);
@@ -303,9 +309,18 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Dom/Util", "WoltLabSui
             const text = getSelectedText().trim();
             if (text !== "") {
                 copyQuote.classList.add("active");
-                message = text;
-                objectId = ~~container.element.dataset.objectId;
+                selectedMessage = {
+                    message: text,
+                    container: container,
+                };
             }
         }, 10);
     }
+    function removeSelection() {
+        copyQuote.classList.remove("active");
+        const selection = window.getSelection();
+        if (selection.rangeCount) {
+            selection.removeAllRanges();
+        }
+    }
 });
index fa5784e7305a504d5ab7f38ad91d20f2fbd7a613..9399bb00c29089149b62bf3f0fc0dfc51d839975 100644 (file)
@@ -12,7 +12,7 @@ define(["require", "exports", "WoltLabSuite/Core/Component/Quote/Message"], func
          * Initializes the quote handler for given object type.
          */
         constructor(quoteManager, className, objectType, containerSelector, messageBodySelector, messageContentSelector, supportDirectInsert) {
-            (0, Message_1.registerContainer)(containerSelector, messageBodySelector);
+            (0, Message_1.registerContainer)(containerSelector, messageBodySelector, objectType);
         }
     }
     exports.UiMessageQuote = UiMessageQuote;
index 5c673485c3aaddd8e724e08fa0394324b36af7a3..c93763849899467a3b6b0a18ae7b465f490e9326 100644 (file)
@@ -137,6 +137,7 @@ return static function (): void {
             $event->register(new \wcf\system\endpoint\controller\core\comments\responses\UpdateResponse());
             $event->register(new \wcf\system\endpoint\controller\core\cronjobs\logs\ClearLogs());
             $event->register(new \wcf\system\endpoint\controller\core\messages\GetMentionSuggestions());
+            $event->register(new \wcf\system\endpoint\controller\core\messages\RenderQuote());
             $event->register(new \wcf\system\endpoint\controller\core\sessions\DeleteSession());
             $event->register(new \wcf\system\endpoint\controller\core\versionTrackers\RevertVersion());
             $event->register(new \wcf\system\endpoint\controller\core\moderationQueues\ChangeJustifiedStatus());
diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/messages/RenderQuote.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/messages/RenderQuote.class.php
new file mode 100644 (file)
index 0000000..76d6ba5
--- /dev/null
@@ -0,0 +1,63 @@
+<?php
+
+namespace wcf\system\endpoint\controller\core\messages;
+
+use Laminas\Diactoros\Response\JsonResponse;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use wcf\data\IMessage;
+use wcf\http\Helper;
+use wcf\system\endpoint\GetRequest;
+use wcf\system\endpoint\IController;
+use wcf\system\html\input\HtmlInputProcessor;
+
+/**
+ * Renders a quote message.
+ *
+ * @author    Olaf Braun
+ * @copyright 2001-2024 WoltLab GmbH
+ * @license   GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since     6.2
+ */
+#[GetRequest('/core/messages/renderquote')]
+final class RenderQuote implements IController
+{
+    #[\Override]
+    public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface
+    {
+        $parameters = Helper::mapApiParameters($request, GetRenderQuoteParameters::class);
+
+        return new JsonResponse(
+            $this->renderFullQuote($parameters),
+            200,
+        );
+    }
+
+    private function renderFullQuote(GetRenderQuoteParameters $parameters): string
+    {
+        // TODO load object
+        /** @var $object IMessage */
+        // TODO load embedded objects?
+
+        $htmlInputProcessor = new HtmlInputProcessor();
+        $htmlInputProcessor->processIntermediate($object->getMessage());
+
+        if (MESSAGE_MAX_QUOTE_DEPTH) {
+            $htmlInputProcessor->enforceQuoteDepth(MESSAGE_MAX_QUOTE_DEPTH - 1, true);
+        }
+
+        return $htmlInputProcessor->getHtml();
+    }
+}
+
+/** @internal */
+final class GetRenderQuoteParameters
+{
+    public function __construct(
+        /** @var non-empty-string */
+        public readonly string $objectType,
+        /** @var positive-int */
+        public readonly int $objectID,
+    ) {
+    }
+}