Flatten threads: add UI components to messages

- A quote is added to messages to show which was the previous message in
  the reply chain.
- A "reply" button is added to messages to allow users to compose a
  comment which is added at the end of the reply chain.
- A bug is fixed in calculating the parent reply ID in the extra info
  object. Now parent reply means the first message in a reply chain,
  while previous reply means the previous message in the reply chain.

Bug: twpowertools:153
Change-Id: I699507ade52e80287dd634e61f835d53af6a904d
diff --git a/src/contentScripts/communityConsole/flattenThreads/components/QuoteAuthor.js b/src/contentScripts/communityConsole/flattenThreads/components/QuoteAuthor.js
new file mode 100644
index 0000000..2f1fb95
--- /dev/null
+++ b/src/contentScripts/communityConsole/flattenThreads/components/QuoteAuthor.js
@@ -0,0 +1,60 @@
+import '@material/web/icon/icon.js';
+import '@material/web/iconbutton/standard-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;
+    }
+
+    .name {
+      overflow: hidden;
+      white-space: nowrap;
+      text-overflow: ellipsis;
+    }
+    `,
+  ];
+
+  constructor() {
+    super();
+    this.prevMessage = {};
+  }
+
+  render() {
+    return html`
+      <md-icon>reply</md-icon>
+      <span class="name">${this.prevMessage?.author?.[1]?.[1]}</span>
+      <md-standard-icon-button
+          icon="arrow_upward"
+          @click=${this.focusParent}>
+      </md-standard-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/contentScripts/communityConsole/flattenThreads/components/ReplyButton.js b/src/contentScripts/communityConsole/flattenThreads/components/ReplyButton.js
new file mode 100644
index 0000000..681c433
--- /dev/null
+++ b/src/contentScripts/communityConsole/flattenThreads/components/ReplyButton.js
@@ -0,0 +1,99 @@
+import '@material/web/button/outlined-button.js';
+
+import {css, html, LitElement} from 'lit';
+import {waitFor} from 'poll-until-promise';
+
+import {SHARED_MD3_STYLES} from '../../../../common/styles/md3.js';
+import {getExtraInfoNodes} from '../flattenThreads.js';
+
+export default class TwptFlattenThreadReplyButton extends LitElement {
+  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;
+    }
+    `,
+  ];
+
+  constructor() {
+    super();
+    this.extraInfo = {};
+  }
+
+  render() {
+    return html`
+      <md-outlined-button
+          label="Reply"
+          @click=${this.openReplyEditor}>
+      </md-outlined-button>
+    `;
+  }
+
+  #defaultReply(messagePayload) {
+    const quoteHeader = document.createElement('div');
+    const italics = document.createElement('i');
+    italics.textContent = this.extraInfo?.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;
+    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 => {
+      const payload =
+          editor?.querySelector('.scTailwindSharedRichtexteditoreditor');
+
+      payload.prepend(...this.#defaultReply(messagePayload.innerHTML));
+    });
+  }
+}
+window.customElements.define(
+    'twpt-flatten-thread-reply-button', TwptFlattenThreadReplyButton);
diff --git a/src/contentScripts/communityConsole/flattenThreads/components/index.js b/src/contentScripts/communityConsole/flattenThreads/components/index.js
new file mode 100644
index 0000000..08f1d18
--- /dev/null
+++ b/src/contentScripts/communityConsole/flattenThreads/components/index.js
@@ -0,0 +1,136 @@
+import '@material/web/button/tonal-button.js';
+
+import './QuoteAuthor.js';
+
+// Other components imported so they are also injected:
+import './ReplyButton.js';
+
+import * as DOMPurify from 'dompurify';
+import {css, html, LitElement} from 'lit';
+import {classMap} from 'lit/directives/class-map.js';
+import {unsafeHTML} from 'lit/directives/unsafe-html.js';
+
+import {SHARED_MD3_STYLES} from '../../../../common/styles/md3.js';
+
+export default class TwptFlattenThreadQuote extends LitElement {
+  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: 4rem;
+      overflow: hidden;
+    }
+
+    .payload-container twpt-flatten-thread-quote-author {
+      float: right;
+      margin-left: 12px;
+      margin-top: -12px;
+      shape-outside: inset(0 10px 10px 0);
+    }
+
+    .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;
+    }
+
+    .quote-container:not(:hover) .buttons-row:not(:has(md-tonal-button:focus)) {
+      opacity: 0;
+    }
+
+    .buttons-row md-tonal-button {
+      --md-tonal-button-container-color: var(--TWPT-dark-flatten-replies-more-bg, rgba(222, 222, 222, 0.9));
+      --md-tonal-button-label-text-color: var(--TWPT-md-sys-color-on-surface);
+      --md-tonal-button-hover-label-text-color: var(--TWPT-md-sys-color-on-surface);
+      --md-tonal-button-focus-label-text-color: var(--TWPT-md-sys-color-on-surface);
+      --md-tonal-button-pressed-label-text-color: var(--TWPT-md-sys-color-on-surface);
+      --md-tonal-button-with-icon-icon-color: var(--TWPT-md-sys-color-on-surface);
+      --md-tonal-button-with-icon-hover-icon-color: var(--TWPT-md-sys-color-on-surface);
+      --md-tonal-button-with-icon-focus-icon-color: var(--TWPT-md-sys-color-on-surface);
+      --md-tonal-button-with-icon-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,
+    });
+    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-tonal-button
+              icon="${this._expanded ? 'expand_less' : 'expand_more'}"
+              label="${this._expanded ? 'Less' : 'More'}"
+              @click=${this.toggleExpanded}>
+          </md-tonal-button>
+        </div>
+      </div>
+    `;
+  }
+
+  toggleExpanded() {
+    this._expanded = !this._expanded;
+  }
+}
+window.customElements.define(
+    'twpt-flatten-thread-quote', TwptFlattenThreadQuote);
diff --git a/src/contentScripts/communityConsole/flattenThreads/flattenThreads.js b/src/contentScripts/communityConsole/flattenThreads/flattenThreads.js
index 91bcfbb..6fc3eac 100644
--- a/src/contentScripts/communityConsole/flattenThreads/flattenThreads.js
+++ b/src/contentScripts/communityConsole/flattenThreads/flattenThreads.js
@@ -4,6 +4,24 @@
 
 export const kReplyPayloadSelector =
     '.scTailwindThreadMessageMessagecardcontent:not(.scTailwindThreadMessageMessagecardpromoted) .scTailwindThreadPostcontentroot html-blob';
+export const kReplyActionButtonsSelector =
+    '.scTailwindThreadMessageMessagecardcontent:not(.scTailwindThreadMessageMessagecardpromoted) sc-tailwind-thread-message-message-actions';
+export const kMatchingSelectors = [
+  kReplyPayloadSelector,
+  kReplyActionButtonsSelector,
+];
+
+export function getExtraInfoNodes(node) {
+  const confirmedNodes = [];
+  const possibleExtraInfoNodes =
+      node.querySelectorAll('span[style*=\'display\'][style*=\'none\']');
+  for (const candidate of possibleExtraInfoNodes) {
+    const content = candidate.textContent;
+    const matches = content.match(kAdditionalInfoRegex);
+    if (matches) confirmedNodes.push(candidate);
+  }
+  return confirmedNodes;
+}
 
 export default class FlattenThreads {
   construct() {}
@@ -33,13 +51,18 @@
 
   injectQuote(node, extraInfo) {
     const content = node.closest('.scTailwindThreadPostcontentroot');
-    // @TODO: Change this by the actual quote component
-    const quote = document.createElement('div');
-    quote.textContent = 'QUOTE(' + extraInfo.parentMessage.id + ')';
+    const quote = document.createElement('twpt-flatten-thread-quote');
+    quote.setAttribute('prevMessage', JSON.stringify(extraInfo.prevMessage));
     content.prepend(quote);
   }
 
-  injectIfApplicable(node) {
+  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);
@@ -49,7 +72,24 @@
     if (extraInfo.isComment) this.injectQuote(node, extraInfo);
   }
 
-  shouldInject(node) {
+  shouldInjectQuote(node) {
     return node.matches(kReplyPayloadSelector);
   }
+
+  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) return;
+
+    this.injectReplyBtn(node, extraInfo);
+  }
+
+  shouldInjectReplyBtn(node) {
+    return node.matches(kReplyActionButtonsSelector);
+  }
 }
diff --git a/src/contentScripts/communityConsole/main.js b/src/contentScripts/communityConsole/main.js
index a3b1bb7..2061834 100644
--- a/src/contentScripts/communityConsole/main.js
+++ b/src/contentScripts/communityConsole/main.js
@@ -8,7 +8,7 @@
 // #!if ['chromium', 'chromium_mv3'].includes(browser_target)
 import {applyDragAndDropFixIfEnabled} from './dragAndDropFix.js';
 // #!endif
-import {default as FlattenThreads, kReplyPayloadSelector} from './flattenThreads/flattenThreads.js';
+import {default as FlattenThreads, kMatchingSelectors as kFlattenThreadMatchingSelectors} from './flattenThreads/flattenThreads.js';
 import InfiniteScroll from './infiniteScroll.js';
 import {kRepliesSectionSelector} from './threadToolbar/constants.js';
 import ThreadToolbar from './threadToolbar/threadToolbar.js';
@@ -79,7 +79,7 @@
   kRepliesSectionSelector,
 
   // Reply payload (for the flatten threads UI)
-  kReplyPayloadSelector,
+  ...kFlattenThreadMatchingSelectors,
 ];
 
 function handleCandidateNode(node) {
@@ -220,20 +220,33 @@
     }
 
     // Inject parent reply quote
-    if (flattenThreads.shouldInject(node)) {
-      flattenThreads.injectIfApplicable(node);
+    if (flattenThreads.shouldInjectQuote(node)) {
+      flattenThreads.injectQuoteIfApplicable(node);
+    }
+
+    // Inject reply button in non-nested view
+    if (flattenThreads.shouldInjectReplyBtn(node)) {
+      flattenThreads.injectReplyBtnIfApplicable(node);
     }
   }
 }
 
-function handleRemovedNode(node) {
+function handleRemovedNode(mutation, node) {
+  if (!('tagName' in node)) return;
+
   // Remove snackbar when exiting thread list view
-  if ('tagName' in node && node.tagName == 'EC-THREAD-LIST') {
+  if (node.tagName == 'EC-THREAD-LIST') {
     window.TWPTAutoRefresh.hideUpdatePrompt();
   }
+
+  // Readd reply button when the Community Console removes it
+  if (node.tagName == 'TWPT-FLATTEN-THREAD-REPLY-BUTTON') {
+    flattenThreads.injectReplyBtn(
+        mutation.target, JSON.parse(node.getAttribute('extraInfo')));
+  }
 }
 
-function mutationCallback(mutationList, observer) {
+function mutationCallback(mutationList) {
   mutationList.forEach((mutation) => {
     if (mutation.type == 'childList') {
       mutation.addedNodes.forEach(function(node) {
@@ -241,7 +254,7 @@
       });
 
       mutation.removedNodes.forEach(function(node) {
-        handleRemovedNode(node);
+        handleRemovedNode(mutation, node);
       });
     }
   });
@@ -325,4 +338,6 @@
   injectScript(chrome.runtime.getURL('litComponentsInject.bundle.js'));
   // Thread toolbar
   injectStylesheet(chrome.runtime.getURL('css/thread_toolbar.css'));
+  // Flatten threads
+  injectStylesheet(chrome.runtime.getURL('css/flatten_threads.css'));
 });
diff --git a/src/injections/litComponentsInject.js b/src/injections/litComponentsInject.js
index f4d59bd..9506c8c 100644
--- a/src/injections/litComponentsInject.js
+++ b/src/injections/litComponentsInject.js
@@ -4,6 +4,7 @@
 // because `window.customElements` doesn't exist in content scripts.
 import '../contentScripts/communityConsole/workflows/components/index.js';
 import '../contentScripts/communityConsole/threadToolbar/components/index.js';
+import '../contentScripts/communityConsole/flattenThreads/components/index.js';
 
 import {injectStylesheet} from '../common/contentScriptsUtils.js';
 
diff --git a/src/static/css/ccdarktheme.css b/src/static/css/ccdarktheme.css
index 0282658..a793a51 100644
--- a/src/static/css/ccdarktheme.css
+++ b/src/static/css/ccdarktheme.css
@@ -40,6 +40,9 @@
   --TWPT-md-ripple-hover-state-layer-color: white;
   --TWPT-md-ripple-pressed-state-layer-color: white;
   --TWPT-custom-md-icon-color: var(--TWPT-subtle-button-background);
+
+  /* TWPT features variables */
+  --TWPT-dark-flatten-replies-more-bg: rgba(89, 89, 89, 0.9);
 }
 
 body {
diff --git a/src/static/css/flatten_threads.css b/src/static/css/flatten_threads.css
new file mode 100644
index 0000000..8a06ef3
--- /dev/null
+++ b/src/static/css/flatten_threads.css
@@ -0,0 +1,12 @@
+body.TWPT-flattenthreads-enabled ec-thread sc-tailwind-thread-message-message-list:last-child sc-tailwind-thread-message-message-actions {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  gap: 0.75rem;
+  margin-top: 1rem;
+}
+
+body.TWPT-flattenthreads-enabled ec-thread sc-tailwind-thread-message-message-list:last-child sc-tailwind-thread-message-message-actions > * {
+  margin: 0;
+  padding: 0;
+}
diff --git a/src/static/css/thread_toolbar.css b/src/static/css/thread_toolbar.css
index cd1ac48..4c1b35f 100644
--- a/src/static/css/thread_toolbar.css
+++ b/src/static/css/thread_toolbar.css
@@ -4,6 +4,28 @@
 }
 
 /* Hide reply button when a thread is flattened, since it might not work correctly */
-body.TWPT-flattenthreads-enabled ec-thread sc-tailwind-thread-message-message-list:last-child .scTailwindThreadMessageMessagecardsub-content {
-  display: none;
+@supports selector(:has(div)) {
+  body.TWPT-flattenthreads-enabled ec-thread sc-tailwind-thread-message-message-list:last-child .scTailwindThreadMessageMessagecardsub-content:not(:has(.scTailwindThreadMessageMessagecardreply-editor)) {
+    display: none;
+  }
+}
+/* Fallback support for browsers which do not support :has(...) */
+@supports not selector(:has(div)) {
+  body.TWPT-flattenthreads-enabled ec-thread sc-tailwind-thread-message-message-list:last-child .scTailwindThreadMessageMessagecardsub-content .scTailwindThreadMessageMessagecardaction {
+    padding: 0;
+  }
+
+  body.TWPT-flattenthreads-enabled ec-thread sc-tailwind-thread-message-message-list:last-child .scTailwindThreadMessageMessagecardsub-content .scTailwindThreadMessageMessagecardaction .scTailwindThreadMessageMessagecardadd-comment {
+    display: none;
+  }
+
+  body.TWPT-flattenthreads-enabled ec-thread sc-tailwind-thread-message-message-list:last-child .scTailwindThreadMessageMessagecardsub-content .scTailwindThreadMessageMessagecardaction .scTailwindThreadMessageMessagecardreply-editor {
+    margin: 0.5rem;
+  }
+
+  @media (min-width: 37.5rem) {
+    body.TWPT-flattenthreads-enabled ec-thread sc-tailwind-thread-message-message-list:last-child .scTailwindThreadMessageMessagecardsub-content .scTailwindThreadMessageMessagecardaction .scTailwindThreadMessageMessagecardreply-editor {
+      margin: 0.5rem 3.5rem;
+    }
+  }
 }
diff --git a/src/xhrInterceptor/responseModifiers/flattenThread.js b/src/xhrInterceptor/responseModifiers/flattenThread.js
index cece8b4..6828772 100644
--- a/src/xhrInterceptor/responseModifiers/flattenThread.js
+++ b/src/xhrInterceptor/responseModifiers/flattenThread.js
@@ -27,8 +27,14 @@
 
     // Add some message data to the payload so the extension can show the parent
     // comment/reply in the case of comments.
+    let prevReplyId;
+    let prevReplyParentId;
     mogs.forEach(m => {
-      const info = this.getAdditionalInformation(m, mogs);
+      const info = this.getAdditionalInformation(
+          m, mogs, prevReplyId, prevReplyParentId);
+      prevReplyId = m.getId();
+      prevReplyParentId = info.parentId;
+
       const span = document.createElement('span');
       span.textContent = kAdditionalInfoPrefix + JSON.stringify(info);
       span.setAttribute('style', 'display: none');
@@ -52,25 +58,36 @@
     response[1][8] = response[1][40].length;
     return response;
   },
-  getAdditionalInformation(message, mogs) {
+  getAdditionalInformation(message, mogs, prevReplyId, prevReplyParentId) {
     const id = message.getId();
     const parentId = message.getParentMessageId();
-    const parentMessage =
-        parentId ? mogs.find(m => m.getId() === parentId) : null;
-    if (!parentMessage) {
+    const authorName = message.getAuthor()?.[1]?.[1];
+    if (!parentId) {
       return {
         isComment: false,
         id,
+        authorName,
       };
     }
 
+    let prevId;
+    if (parentId === prevReplyParentId && prevReplyParentId)
+      prevId = prevReplyId;
+    else
+      prevId = parentId;
+
+    const prevMessage =
+        prevId ? mogs.find(m => m.getId() === prevId) : null;
+
     return {
       isComment: true,
       id,
-      parentMessage: {
-        id: parentId,
-        payload: parentMessage.getPayload(),
-        author: parentMessage.getAuthor(),
+      authorName,
+      parentId,
+      prevMessage: {
+        id: prevId,
+        payload: prevMessage.getPayload(),
+        author: prevMessage.getAuthor(),
       },
     };
   }