fix(flatten-threads): handle |#action=reply| correctly

The action to open the reply editor is usually handled by the Community
Console. With the flatten threads enabled, this sometimes opens a reply
editor corresponding to a nested reply which when used wouldn't post the
reply correctly.

Thus, this CL adds logic to handle the |#action=reply| ourselves to open
the reply editor adequately, as if the user clicked our own "Reply"
button. This opens the reply editor corresponding to the parent reply
(the first reply in the reply chain).

Fixed: twpowertools:180
Change-Id: I1d734cfe0e28971939d292121ad6144f9e9f7a9a
diff --git a/src/common/commonUtils.js b/src/common/commonUtils.js
index 3e0be66..76a23d9 100644
--- a/src/common/commonUtils.js
+++ b/src/common/commonUtils.js
@@ -1,6 +1,7 @@
 export function parseUrl(url) {
   var forum_a = url.match(/forum\/([0-9]+)/i);
   var thread_a = url.match(/thread\/([0-9]+)/i);
+  var message_a = url.match(/message\/([0-9]+)/i);
 
   if (forum_a === null || thread_a === null) {
     return false;
@@ -9,6 +10,7 @@
   return {
     'forum': forum_a[1],
     'thread': thread_a[1],
+    'message': message_a !== null ? message_a[1] : null,
   };
 }
 
diff --git a/src/contentScripts/communityConsole/flattenThreads/components/ReplyButton.js b/src/contentScripts/communityConsole/flattenThreads/components/ReplyButton.js
index a8aeaba..6a35b10 100644
--- a/src/contentScripts/communityConsole/flattenThreads/components/ReplyButton.js
+++ b/src/contentScripts/communityConsole/flattenThreads/components/ReplyButton.js
@@ -2,10 +2,10 @@
 
 import {msg, str} from '@lit/localize';
 import {css, html} from 'lit';
-import {waitFor} from 'poll-until-promise';
 
 import {I18nLitElement} from '../../../../common/litI18nUtils.js';
 import {SHARED_MD3_STYLES} from '../../../../common/styles/md3.js';
+import {openReplyEditor} from '../../utils/common.js';
 import {getExtraInfoNodes} from '../flattenThreads.js';
 
 export default class TwptFlattenThreadReplyButton extends I18nLitElement {
@@ -26,6 +26,7 @@
   constructor() {
     super();
     this.extraInfo = {};
+    this.addEventListener('twpt-click', this.openReplyEditor);
   }
 
   render() {
@@ -69,30 +70,7 @@
     }
 
     const parentId = this.extraInfo?.parentId ?? this.extraInfo?.id;
-    const parentNodeReply =
-        document.querySelector('[data-twpt-message-id="' + parentId + '"]')
-            ?.closest?.('sc-tailwind-thread-message-message-card');
-    const parentNodeReplyButton = parentNodeReply?.querySelector?.(
-        '.scTailwindThreadMessageMessagecardadd-comment button');
-    if (!parentNodeReplyButton) {
-      // This is not critical: the reply button might already have been clicked
-      // (so it no longer exists), or the thread might be locked so replying is
-      // disabled and the button does'nt exist.
-      console.debug('[flattenThreads] Reply button not found.');
-      return;
-    }
-
-    // Click the reply button.
-    parentNodeReplyButton.click();
-
-    // Fill in the default reply text (it includes a quote of the message the
-    // user wishes to reply to).
-    waitFor(() => {
-      const editor =
-          parentNodeReply?.querySelector('sc-tailwind-thread-reply-editor');
-      if (editor) return Promise.resolve(editor);
-      return Promise.reject(new Error('Editor not found.'));
-    }, {interval: 75, timeout: 10 * 1000}).then(editor => {
+    openReplyEditor(parentId).then(editor => {
       const payload =
           editor?.querySelector('.scTailwindSharedRichtexteditoreditor');
 
diff --git a/src/contentScripts/communityConsole/flattenThreads/replyActionHandler.js b/src/contentScripts/communityConsole/flattenThreads/replyActionHandler.js
new file mode 100644
index 0000000..99ef0fc
--- /dev/null
+++ b/src/contentScripts/communityConsole/flattenThreads/replyActionHandler.js
@@ -0,0 +1,74 @@
+import {waitFor} from 'poll-until-promise';
+
+import {parseUrl} from '../../../common/commonUtils';
+import {getOptions} from '../../../common/optionsUtils';
+
+const kOpenReplyEditorIntervalInMs = 500;
+const kOpenReplyEditorTimeoutInMs = 10 * 1000;
+
+// @TODO: Handle observing when the hash is added after the page has loaded.
+export default class FlattenThreadsReplyActionHandler {
+  /**
+   * @param {Object} options Options object which at least includes the
+   *     |flattenthreads| and |flattenthreads_switch_enabled| options.
+   */
+  constructor(options = null) {
+    this.options = options;
+  }
+
+  async handleIfApplicable() {
+    if (await this.isFeatureEnabled()) this.handle();
+  }
+
+  async handle() {
+    const hash = window.location.hash;
+    if (hash === '#action=reply') await this.openReplyEditor();
+  }
+
+  async openReplyEditor() {
+    // We erase the hash so the Community Console doesn't open the reply
+    // editor, since we're going to do that instead.
+    window.location.hash = '';
+
+    const messageId = this.getCurrentMessageId();
+    if (messageId === null) {
+      console.error(
+          '[FlattenThreadsReplyActionHandler] Could not parse current message id.');
+      return;
+    }
+
+    const replyButton = await waitFor(async () => {
+      const replyButton = document.querySelector(
+          '[data-twpt-message-id="' + messageId +
+          '"] twpt-flatten-thread-reply-button');
+      if (replyButton === null) throw new Error('Reply button not found.');
+      return replyButton;
+    }, {
+      interval: kOpenReplyEditorIntervalInMs,
+      timeout: kOpenReplyEditorTimeoutInMs,
+    });
+
+    const e = new Event('twpt-click');
+    replyButton.dispatchEvent(e);
+  }
+
+  async isFeatureEnabled() {
+    let options;
+    if (this.options !== null) {
+      options = this.options;
+    } else {
+      options =
+          await getOptions(['flattenthreads', 'flattenthreads_switch_enabled']);
+    }
+    return options['flattenthreads'] &&
+        options['flattenthreads_switch_enabled'];
+  }
+
+  getCurrentMessageId() {
+    const thread = parseUrl(window.location.href);
+    if (thread === false || thread.message === null) {
+      return null;
+    }
+    return thread.message;
+  }
+}
diff --git a/src/contentScripts/communityConsole/start.js b/src/contentScripts/communityConsole/start.js
index 294c6cd..71b4779 100644
--- a/src/contentScripts/communityConsole/start.js
+++ b/src/contentScripts/communityConsole/start.js
@@ -6,6 +6,7 @@
 
 import AutoRefresh from './autoRefresh.js';
 import ExtraInfo from './extraInfo/index.js';
+import FlattenThreadsReplyActionHandler from './flattenThreads/replyActionHandler.js';
 import ThreadPageDesignWarning from './threadPageDesignWarning.js';
 import WorkflowsImport from './workflows/import.js';
 
@@ -83,4 +84,7 @@
     injectStylesheet(chrome.runtime.getURL('css/ui_spacing/shared.css'));
     injectStylesheet(chrome.runtime.getURL('css/ui_spacing/console.css'));
   }
+
+  const flattenThreadsReplyActionHandler = new FlattenThreadsReplyActionHandler(options);
+  flattenThreadsReplyActionHandler.handleIfApplicable();
 });
diff --git a/src/contentScripts/communityConsole/utils/common.js b/src/contentScripts/communityConsole/utils/common.js
index 3d2bac5..90ff24d 100644
--- a/src/contentScripts/communityConsole/utils/common.js
+++ b/src/contentScripts/communityConsole/utils/common.js
@@ -1,4 +1,5 @@
 import {MDCTooltip} from '@material/tooltip';
+import {waitFor} from 'poll-until-promise';
 
 import {createPlainTooltip} from '../../../common/tooltip.js';
 
@@ -31,8 +32,10 @@
   return [badge, badgeTooltip];
 }
 
-// Adds an element to the thread list actions bar next to the button given by
-// |originalBtn|.
+/**
+ * Adds an element to the thread list actions bar next to the button given by
+ * |originalBtn|.
+ */
 export function addElementToThreadListActions(originalBtn, element) {
   var duplicateBtn =
       originalBtn.parentNode.querySelector('[debugid="mark-duplicate-button"]');
@@ -44,9 +47,11 @@
         element, (originalBtn.nextSibling || originalBtn));
 }
 
-// Adds a button to the thread list actions bar next to the button given by
-// |originalBtn|. The button will have icon |icon|, when hovered it will display
-// |tooltip|, and will have a debugid attribute with value |debugId|.
+/**
+ * Adds a button to the thread list actions bar next to the button given by
+ * |originalBtn|. The button will have icon |icon|, when hovered it will display
+ * |tooltip|, and will have a debugid attribute with value |debugId|.
+ */
 export function addButtonToThreadListActions(
     originalBtn, icon, debugId, tooltip) {
   let clone = originalBtn.cloneNode(true);
@@ -67,9 +72,11 @@
   return clone;
 }
 
-// Returns true if |node| is the "mark as read/unread" button, the parent of the
-// parent of |node| is the actions bar of the thread list, and the button with
-// debugid |debugid| is NOT part of the actions bar.
+/**
+ * Returns true if |node| is the "mark as read/unread" button, the parent of the
+ * parent of |node| is the actions bar of the thread list, and the button with
+ * debugid |debugid| is NOT part of the actions bar.
+ */
 export function shouldAddBtnToActionBar(debugid, node) {
   return node?.tagName == 'MATERIAL-BUTTON' &&
       (node.getAttribute?.('debugid') == 'mark-read-button' ||
@@ -78,15 +85,19 @@
       node.parentNode?.parentNode?.tagName == 'EC-BULK-ACTIONS';
 }
 
-// Returns the display language set by the user.
+/**
+ * Returns the display language set by the user.
+ */
 export function getDisplayLanguage() {
   var startup =
       JSON.parse(document.querySelector('html').getAttribute('data-startup'));
   return startup?.[1]?.[1]?.[3]?.[6] ?? 'en';
 }
 
-// Refreshes the current view in the Community Console without reloading the
-// whole page if possible.
+/**
+ * Refreshes the current view in the Community Console without reloading the
+ * whole page if possible.
+ */
 export function softRefreshView() {
   const refreshButton = document.querySelector('.app-title-button');
   if (refreshButton == null)
@@ -94,3 +105,32 @@
   else
     refreshButton.click();
 }
+
+/**
+ * Opens the vanilla Community Console reply editor.
+ * @param {string} messageId The id of the message for which to open the reply
+ * editor.
+ * @returns {Promise<Element>} A promise resolving to the reply editor.
+ */
+export async function openReplyEditor(messageId) {
+  const nodeReply =
+      document.querySelector('[data-twpt-message-id="' + messageId + '"]')
+          ?.closest?.('sc-tailwind-thread-message-message-card');
+  const nodeReplyButton = nodeReply?.querySelector?.(
+      '.scTailwindThreadMessageMessagecardadd-comment button');
+  if (!nodeReplyButton) {
+    // This is not critical: the reply button might already have been clicked
+    // (so it no longer exists), or the thread might be locked so replying is
+    // disabled and the button doesn't exist.
+    console.debug('[flattenThreads] Reply button not found.');
+    return null;
+  }
+
+  nodeReplyButton.click();
+
+  return await waitFor(() => {
+    const editor = nodeReply?.querySelector('sc-tailwind-thread-reply-editor');
+    if (editor) return Promise.resolve(editor);
+    return Promise.reject(new Error('Editor not found.'));
+  }, {interval: 75, timeout: 10 * 1000});
+}