refactor(flatten-threads): migrate to the new DI architecture

Bug: twpowertools:176
Change-Id: I7a24fb504ce53697112f11128d2d5249a5a7c7e7
diff --git a/src/features/flattenThreads/core/components/QuoteAuthor.js b/src/features/flattenThreads/core/components/QuoteAuthor.js
new file mode 100644
index 0000000..7f93e9a
--- /dev/null
+++ b/src/features/flattenThreads/core/components/QuoteAuthor.js
@@ -0,0 +1,65 @@
+import '@material/web/icon/icon.js';
+import '@material/web/iconbutton/icon-button.js';
+
+import {css, html, LitElement} from 'lit';
+
+import {SHARED_MD3_STYLES} from '../../../../common/styles/md3.js';
+
+export default class TwptFlattenThreadQuoteAuthor extends LitElement {
+  static properties = {
+    prevMessage: {type: Object},
+  };
+
+  static styles = [
+    SHARED_MD3_STYLES,
+    css`
+    :host {
+      display: flex;
+      flex-direction: row;
+      align-items: center;
+      max-width: max(25%, 150px);
+      color: var(--TWPT-interop-secondary-text, #444746);
+    }
+
+    :host > *:not(:last-child) {
+      margin-right: 4px;
+    }
+
+    .reply-icon {
+      font-size: 20px;
+    }
+
+    .name {
+      overflow: hidden;
+      white-space: nowrap;
+      text-overflow: ellipsis;
+      font-size: 15px;
+    }
+    `,
+  ];
+
+  constructor() {
+    super();
+    this.prevMessage = {};
+  }
+
+  render() {
+    return html`
+      <md-icon class="reply-icon">reply</md-icon>
+      <span class="name">${this.prevMessage?.author?.[1]?.[1]}</span>
+      <md-icon-button
+          @click=${this.focusParent}>
+        <md-icon>arrow_upward</md-icon>
+      </md-icon-button>
+    `;
+  }
+
+  focusParent() {
+    const parentNode = document.querySelector(
+        '[data-twpt-message-id="' + this.prevMessage?.id + '"]');
+    parentNode.focus({preventScroll: true});
+    parentNode.scrollIntoView({behavior: 'smooth', block: 'start'});
+  }
+}
+window.customElements.define(
+    'twpt-flatten-thread-quote-author', TwptFlattenThreadQuoteAuthor);
diff --git a/src/features/flattenThreads/core/components/ReplyButton.js b/src/features/flattenThreads/core/components/ReplyButton.js
new file mode 100644
index 0000000..d54a981
--- /dev/null
+++ b/src/features/flattenThreads/core/components/ReplyButton.js
@@ -0,0 +1,99 @@
+import '@material/web/button/outlined-button.js';
+
+import {msg, str} from '@lit/localize';
+import {css, html} from 'lit';
+
+import {I18nLitElement} from '../../../../common/litI18nUtils.js';
+import {SHARED_MD3_STYLES} from '../../../../common/styles/md3.js';
+import {openReplyEditor} from '../../../../contentScripts/communityConsole/utils/common.js';
+import {getExtraInfoNodes} from '../flattenThreads.js';
+
+export default class TwptFlattenThreadReplyButton extends I18nLitElement {
+  static properties = {
+    extraInfo: {type: Object},
+  };
+
+  static styles = [
+    SHARED_MD3_STYLES,
+    css`
+    md-outlined-button {
+      --md-outlined-button-container-shape: 0.25rem;
+      --md-outlined-button-container-height: 38px;
+
+      /**
+       * We change the color because otherwise it would have a very similar
+       * color to the "Recommend" button.
+       */
+      --md-outlined-button-label-text-color: var(--reply-button-color);
+      --md-outlined-button-focus-label-text-color: var(--reply-button-color);
+      --md-outlined-button-hover-label-text-color: var(--reply-button-color);
+      --md-outlined-button-hover-state-layer-color: var(--reply-button-color);
+      --md-outlined-button-label-text-color: var(--reply-button-color);
+      --md-outlined-button-pressed-label-text-color: var(--reply-button-color);
+      --md-outlined-button-pressed-state-layer-color: var(--reply-button-color);
+      --md-outlined-button-focus-icon-color: var(--reply-button-color);
+      --md-outlined-button-hover-icon-color: var(--reply-button-color);
+      --md-outlined-button-icon-color: var(--reply-button-color);
+      --md-outlined-button-pressed-icon-color: var(--reply-button-color);
+    }
+    `,
+  ];
+
+  constructor() {
+    super();
+    this.extraInfo = {};
+    this.addEventListener('twpt-click', this.openReplyEditor);
+  }
+
+  render() {
+    return html`
+      <md-outlined-button
+          @click=${this.openReplyEditor}>
+        ${msg('Reply', {
+      desc: 'Button which is used to open the reply box.',
+    })}
+      </md-outlined-button>
+    `;
+  }
+
+  #defaultReply(messagePayload) {
+    const quoteHeader = document.createElement('div');
+    const italics = document.createElement('i');
+    const authorName = this.extraInfo?.authorName;
+    italics.textContent = msg(str`${authorName} said:`);
+    quoteHeader.append(italics);
+
+    const quote = document.createElement('blockquote');
+    quote.innerHTML = messagePayload;
+    getExtraInfoNodes(quote)?.forEach?.(node => {
+      node.parentNode.removeChild(node);
+    });
+
+    const br1 = document.createElement('br');
+    const br2 = document.createElement('br');
+
+    return [quoteHeader, quote, br1, br2];
+  }
+
+  openReplyEditor() {
+    const messageId = this.extraInfo?.id;
+    const messagePayload = document.querySelector(
+        '[data-twpt-message-id="' + messageId +
+        '"] .scTailwindThreadPostcontentroot html-blob');
+    if (!messagePayload) {
+      console.error('[flattenThreads] Payload not found.');
+      return;
+    }
+
+    const parentId = this.extraInfo?.parentId ?? this.extraInfo?.id;
+    openReplyEditor(parentId).then(editor => {
+      const payload =
+          editor?.querySelector('.scTailwindSharedRichtexteditoreditor');
+
+      payload.prepend(...this.#defaultReply(messagePayload.innerHTML));
+      payload.scrollTop = payload.scrollHeight;
+    });
+  }
+}
+window.customElements.define(
+    'twpt-flatten-thread-reply-button', TwptFlattenThreadReplyButton);
diff --git a/src/features/flattenThreads/core/components/index.js b/src/features/flattenThreads/core/components/index.js
new file mode 100644
index 0000000..f1da3b0
--- /dev/null
+++ b/src/features/flattenThreads/core/components/index.js
@@ -0,0 +1,153 @@
+import '@material/web/button/filled-tonal-button.js';
+import '@material/web/icon/icon.js';
+import './QuoteAuthor.js';
+
+// Other components imported so they are also injected:
+import './ReplyButton.js';
+
+import {msg} from '@lit/localize';
+import * as DOMPurify from 'dompurify';
+import {css, html} from 'lit';
+import {classMap} from 'lit/directives/class-map.js';
+import {unsafeHTML} from 'lit/directives/unsafe-html.js';
+
+import {I18nLitElement} from '../../../../common/litI18nUtils.js';
+import {SHARED_MD3_STYLES} from '../../../../common/styles/md3.js';
+
+export default class TwptFlattenThreadQuote extends I18nLitElement {
+  static properties = {
+    prevMessage: {type: Object},
+    _expanded: {type: Boolean},
+  };
+
+  static styles = [
+    SHARED_MD3_STYLES,
+    css`
+    :host {
+      display: block;
+    }
+
+    .quote-container {
+      position: relative;
+      background-color: var(--TWPT-secondary-background, rgba(242, 242, 242, 0.502));
+      margin-bottom: 16px;
+      padding: 0 12px 12px 12px;
+      border-radius: 12px;
+    }
+
+    .payload-container {
+      padding-top: 12px;
+    }
+
+    .quote-container:not(.quote-container--expanded) .payload-container {
+      max-height: 2.8rem;
+      overflow: hidden;
+      mask-image: linear-gradient(rgb(0, 0, 0) 76%, transparent);
+      -webkit-mask-image: linear-gradient(rgb(0, 0, 0) 76%, transparent);
+    }
+
+    .payload-container twpt-flatten-thread-quote-author {
+      float: right;
+      margin-left: 12px;
+      margin-top: -8px;
+    }
+
+    .payload {
+      display: inline;
+    }
+
+    .payload img {
+      max-width: 100%;
+      max-height: calc(100vh - 2*64px);
+    }
+
+    .payload blockquote {
+      border-left: 1px solid #757575;
+      margin: 0 0 0 4px;
+      padding: 0 0 0 4px;
+    }
+
+    .buttons-row {
+      position: absolute;
+      width: calc(100% - 24px);
+      display: flex;
+      flex-direction: row;
+      justify-content: center;
+      bottom: -20px;
+      transition: opacity .25s;
+    }
+
+    @media (hover: hover) {
+      @supports selector(:has(div)) {
+        .quote-container:not(:hover) .buttons-row:not(:focus-within) {
+          opacity: 0;
+        }
+      }
+    }
+
+    .buttons-row md-filled-tonal-button {
+      --md-filled-tonal-button-container-color: var(--TWPT-dark-flatten-replies-more-bg, rgba(222, 222, 222, 0.9));
+      --md-filled-tonal-button-label-text-color: var(--TWPT-md-sys-color-on-surface);
+      --md-filled-tonal-button-hover-label-text-color: var(--TWPT-md-sys-color-on-surface);
+      --md-filled-tonal-button-focus-label-text-color: var(--TWPT-md-sys-color-on-surface);
+      --md-filled-tonal-button-pressed-label-text-color: var(--TWPT-md-sys-color-on-surface);
+      --md-filled-tonal-button-icon-color: var(--TWPT-md-sys-color-on-surface);
+      --md-filled-tonal-button-hover-icon-color: var(--TWPT-md-sys-color-on-surface);
+      --md-filled-tonal-button-focus-icon-color: var(--TWPT-md-sys-color-on-surface);
+      --md-filled-tonal-button-pressed-icon-color: var(--TWPT-md-sys-color-on-surface);
+    }
+    `,
+  ];
+
+  constructor() {
+    super();
+    this.prevMessage = {};
+    this._expanded = false;
+  }
+
+  getTrustedPayload() {
+    return DOMPurify.sanitize(this.prevMessage?.payload ?? '');
+  }
+
+  render() {
+    const containerClasses = classMap({
+      'quote-container': true,
+      'quote-container--expanded': this._expanded,
+    });
+    const lessMsg = msg('Less', {
+      desc:
+          'Button to collapse the quote message (used in the flatten threads feature).',
+    });
+    const moreMsg = msg('More', {
+      desc:
+          'Button to expand the quote message (used in the flatten threads feature).',
+    });
+    return html`
+      <div class=${containerClasses}>
+        <div class="payload-container">
+          <twpt-flatten-thread-quote-author
+              .prevMessage=${this.prevMessage}>
+          </twpt-flatten-thread-quote-author>
+          <div class="payload">
+            ${unsafeHTML(this.getTrustedPayload())}
+          </div>
+        </div>
+        <div class="buttons-row">
+          <md-filled-tonal-button
+              @click=${this.toggleExpanded}>
+            <md-icon slot="icon">
+              ${this._expanded ? 'expand_less' : 'expand_more'}
+            </md-icon>
+            ${this._expanded ? lessMsg : moreMsg}
+          </md-filled-tonal-button>
+        </div>
+      </div>
+    `;
+  }
+
+  toggleExpanded() {
+    this._expanded = !this._expanded;
+  }
+}
+window.customElements.define(
+    'twpt-flatten-thread-quote', TwptFlattenThreadQuote);
diff --git a/src/features/flattenThreads/core/flattenThreads.js b/src/features/flattenThreads/core/flattenThreads.js
new file mode 100644
index 0000000..1e63e24
--- /dev/null
+++ b/src/features/flattenThreads/core/flattenThreads.js
@@ -0,0 +1,69 @@
+export const kAdditionalInfoClass = 'ck-indent-9996300035194';
+
+export const kReplyPayloadSelector =
+    '.scTailwindThreadMessageMessagecardcontent:not(.scTailwindThreadMessageMessagecardpromoted) .scTailwindThreadPostcontentroot html-blob';
+export const kReplyActionButtonsSelector =
+    '.scTailwindThreadMessageMessagecardcontent:not(.scTailwindThreadMessageMessagecardpromoted) sc-tailwind-thread-message-message-actions';
+export const kAdditionalInfoSelector = '.ck-indent-9996300035194';
+
+export function getExtraInfoNodes(node) {
+  return node.querySelectorAll(kAdditionalInfoSelector);
+}
+
+export default class FlattenThreads {
+  construct() {}
+
+  getExtraInfo(node) {
+    const extraInfoNode = node.querySelector(kAdditionalInfoSelector);
+    if (!extraInfoNode) return null;
+    return JSON.parse(extraInfoNode.textContent);
+  }
+
+  injectId(node, extraInfo) {
+    const root = node.closest('.scTailwindThreadMessageMessagecardcontent');
+    if (!root) return false;
+    root.setAttribute('data-twpt-message-id', extraInfo.id);
+    return true;
+  }
+
+  injectQuote(node, extraInfo) {
+    const content = node.closest('.scTailwindThreadPostcontentroot');
+    const quote = document.createElement('twpt-flatten-thread-quote');
+    quote.setAttribute('prevMessage', JSON.stringify(extraInfo.prevMessage));
+    content.prepend(quote);
+  }
+
+  injectReplyBtn(node, extraInfo) {
+    const btn = document.createElement('twpt-flatten-thread-reply-button');
+    btn.setAttribute('extraInfo', JSON.stringify(extraInfo));
+    node.prepend(btn);
+  }
+
+  injectQuoteIfApplicable(node) {
+    // If we injected the additional information, it means the flatten threads
+    // feature is enabled and in actual use, so we should inject the quote.
+    const extraInfo = this.getExtraInfo(node);
+    if (!extraInfo) return;
+
+    this.injectId(node, extraInfo);
+    if (extraInfo.isComment) this.injectQuote(node, extraInfo);
+  }
+
+  injectReplyBtnIfApplicable(node) {
+    // If we injected the additional information, it means the flatten threads
+    // feature is enabled and in actual use, so we should inject the reply
+    // button.
+    const root =
+        node.closest('.scTailwindThreadMessageMessagecardcontent')
+            .querySelector('.scTailwindThreadMessageMessagecardbody html-blob');
+    const extraInfo = this.getExtraInfo(root);
+    if (!extraInfo || !extraInfo.canComment) return;
+
+    this.injectReplyBtn(node, extraInfo);
+  }
+
+  deleteAdditionalInfoElementIfApplicable(node) {
+    if (!node.closest('sc-tailwind-shared-rich-text-editor')) return;
+    node.remove();
+  }
+}
diff --git a/src/features/flattenThreads/core/replyActionHandler.js b/src/features/flattenThreads/core/replyActionHandler.js
new file mode 100644
index 0000000..3e5d02d
--- /dev/null
+++ b/src/features/flattenThreads/core/replyActionHandler.js
@@ -0,0 +1,63 @@
+import {waitFor} from 'poll-until-promise';
+
+import {parseUrl} from '../../../common/commonUtils';
+
+const kOpenReplyEditorIntervalInMs = 500;
+const kOpenReplyEditorTimeoutInMs = 10 * 1000;
+
+// @TODO: Handle observing when the hash is added after the page has loaded.
+export default class FlattenThreadsReplyActionHandler {
+  constructor(optionsProvider) {
+    this.optionsProvider = optionsProvider;
+  }
+
+  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() {
+    const options = await this.optionsProvider.getOptionsValues();
+    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/features/flattenThreads/presentation/nodeWatcherHandlers/additionalInfo.handler.ts b/src/features/flattenThreads/presentation/nodeWatcherHandlers/additionalInfo.handler.ts
new file mode 100644
index 0000000..c03b5bd
--- /dev/null
+++ b/src/features/flattenThreads/presentation/nodeWatcherHandlers/additionalInfo.handler.ts
@@ -0,0 +1,18 @@
+import CssSelectorNodeWatcherHandler from '../../../../infrastructure/presentation/nodeWatcher/handlers/CssSelectorHandler.adapter';
+import { NodeMutation } from '../../../../presentation/nodeWatcher/NodeWatcherHandler';
+import FlattenThreads, {
+  kAdditionalInfoSelector,
+} from '../../core/flattenThreads';
+
+/** Delete additional info in the edit message box */
+export default class FlattenThreadsAdditionalInfoHandler extends CssSelectorNodeWatcherHandler {
+  cssSelector = kAdditionalInfoSelector;
+
+  constructor(private flattenThreads: FlattenThreads) {
+    super();
+  }
+
+  onMutatedNode({ node }: NodeMutation) {
+    this.flattenThreads.deleteAdditionalInfoElementIfApplicable(node);
+  }
+}
diff --git a/src/features/flattenThreads/presentation/nodeWatcherHandlers/quote.handler.ts b/src/features/flattenThreads/presentation/nodeWatcherHandlers/quote.handler.ts
new file mode 100644
index 0000000..c156049
--- /dev/null
+++ b/src/features/flattenThreads/presentation/nodeWatcherHandlers/quote.handler.ts
@@ -0,0 +1,18 @@
+import CssSelectorNodeWatcherHandler from '../../../../infrastructure/presentation/nodeWatcher/handlers/CssSelectorHandler.adapter';
+import { NodeMutation } from '../../../../presentation/nodeWatcher/NodeWatcherHandler';
+import FlattenThreads, {
+  kReplyPayloadSelector,
+} from '../../core/flattenThreads';
+
+/** Inject parent reply quote */
+export default class FlattenThreadsQuoteHandler extends CssSelectorNodeWatcherHandler {
+  cssSelector = kReplyPayloadSelector;
+
+  constructor(private flattenThreads: FlattenThreads) {
+    super();
+  }
+
+  onMutatedNode({ node }: NodeMutation) {
+    this.flattenThreads.injectQuoteIfApplicable(node);
+  }
+}
diff --git a/src/features/flattenThreads/presentation/nodeWatcherHandlers/readdReplyBtn.handler.ts b/src/features/flattenThreads/presentation/nodeWatcherHandlers/readdReplyBtn.handler.ts
new file mode 100644
index 0000000..2eb3d33
--- /dev/null
+++ b/src/features/flattenThreads/presentation/nodeWatcherHandlers/readdReplyBtn.handler.ts
@@ -0,0 +1,23 @@
+import CssSelectorNodeWatcherHandler from '../../../../infrastructure/presentation/nodeWatcher/handlers/CssSelectorHandler.adapter';
+import {
+  NodeMutation,
+  NodeMutationType,
+} from '../../../../presentation/nodeWatcher/NodeWatcherHandler';
+import FlattenThreads from '../../core/flattenThreads';
+
+/** Readd reply button when the Community Console removes it */
+export default class FlattenThreadsReaddReplyBtnHandler extends CssSelectorNodeWatcherHandler {
+  cssSelector = 'twpt-flatten-thread-reply-button';
+  mutationTypesProcessed = [NodeMutationType.RemovedNode];
+
+  constructor(private flattenThreads: FlattenThreads) {
+    super();
+  }
+
+  onMutatedNode({ node, mutationRecord }: NodeMutation<HTMLElement>) {
+    this.flattenThreads.injectReplyBtn(
+      mutationRecord.target,
+      JSON.parse(node.getAttribute('extraInfo')),
+    );
+  }
+}
diff --git a/src/features/flattenThreads/presentation/nodeWatcherHandlers/replyBtn.handler.ts b/src/features/flattenThreads/presentation/nodeWatcherHandlers/replyBtn.handler.ts
new file mode 100644
index 0000000..66eff17
--- /dev/null
+++ b/src/features/flattenThreads/presentation/nodeWatcherHandlers/replyBtn.handler.ts
@@ -0,0 +1,18 @@
+import CssSelectorNodeWatcherHandler from '../../../../infrastructure/presentation/nodeWatcher/handlers/CssSelectorHandler.adapter';
+import { NodeMutation } from '../../../../presentation/nodeWatcher/NodeWatcherHandler';
+import FlattenThreads, {
+  kReplyActionButtonsSelector,
+} from '../../core/flattenThreads';
+
+/** Inject reply button in non-nested view */
+export default class FlattenThreadsReplyBtnHandler extends CssSelectorNodeWatcherHandler {
+  cssSelector = kReplyActionButtonsSelector;
+
+  constructor(private flattenThreads: FlattenThreads) {
+    super();
+  }
+
+  onMutatedNode({ node }: NodeMutation) {
+    this.flattenThreads.injectReplyBtnIfApplicable(node);
+  }
+}
diff --git a/src/features/flattenThreads/presentation/scripts/setUpReplyActionHandler.script.ts b/src/features/flattenThreads/presentation/scripts/setUpReplyActionHandler.script.ts
new file mode 100644
index 0000000..b62140e
--- /dev/null
+++ b/src/features/flattenThreads/presentation/scripts/setUpReplyActionHandler.script.ts
@@ -0,0 +1,20 @@
+import Script from '../../../../common/architecture/scripts/Script';
+import InjectLitComponentsScript from '../../../../presentation/standaloneScripts/litComponents/injectLitComponents.script';
+import FlattenThreadsReplyActionHandler from '../../core/replyActionHandler';
+
+export default class FlattenThreadsSetUpReplyActionHandlerScript extends Script {
+  page: never;
+  environment: never;
+  runPhase: never;
+  runAfter = [InjectLitComponentsScript];
+
+  constructor(
+    private flattenThreadsReplyActionHandler: FlattenThreadsReplyActionHandler,
+  ) {
+    super();
+  }
+
+  execute() {
+    this.flattenThreadsReplyActionHandler.handleIfApplicable();
+  }
+}
diff --git a/src/features/flattenThreads/presentation/scripts/styles.script.ts b/src/features/flattenThreads/presentation/scripts/styles.script.ts
new file mode 100644
index 0000000..338adb2
--- /dev/null
+++ b/src/features/flattenThreads/presentation/scripts/styles.script.ts
@@ -0,0 +1,12 @@
+import Script from '../../../../common/architecture/scripts/Script';
+import { injectStylesheet } from '../../../../common/contentScriptsUtils';
+
+export default class FlattenThreadsStylesScript extends Script {
+  page: never;
+  environment: never;
+  runPhase: never;
+
+  execute() {
+    injectStylesheet(chrome.runtime.getURL('css/flatten_threads.css'));
+  }
+}