feat(workflows): add "attribute action" action

This action lets users perform several actions on threads, such as
(un)lock, (un)set as trending, (un)pin, set as non-issue, obsolete, etc.

The action selector shows the action with the codename it has in the
Protobuf enum. We will show a friendly string when we localize the
feature.

Bug: twpowertools:74

Change-Id: I95f9f1904ffe559c92a786cbdb327613c8ca32ca
diff --git a/src/contentScripts/communityConsole/workflows/actionRunners/attribute.js b/src/contentScripts/communityConsole/workflows/actionRunners/attribute.js
new file mode 100644
index 0000000..bec2511
--- /dev/null
+++ b/src/contentScripts/communityConsole/workflows/actionRunners/attribute.js
@@ -0,0 +1,20 @@
+import {CCApi} from '../../../../common/api.js';
+import {getAuthUser} from '../../../../common/communityConsoleUtils.js';
+
+export default class AttributeRunner {
+  async execute(attributeAction, thread) {
+    if (!attributeAction) {
+      throw new Error(
+          'The workflow is malformed. The attribute action is missing.');
+    }
+    const action = attributeAction.getAttributeAction();
+
+    return await CCApi(
+        'SetThreadAttribute', {
+          1: thread.forumId,
+          2: thread.threadId,
+          3: action,
+        },
+        /* authenticated = */ true, getAuthUser());
+  }
+}
diff --git a/src/contentScripts/communityConsole/workflows/runner.js b/src/contentScripts/communityConsole/workflows/runner.js
index 35c291c..164d226 100644
--- a/src/contentScripts/communityConsole/workflows/runner.js
+++ b/src/contentScripts/communityConsole/workflows/runner.js
@@ -1,8 +1,9 @@
-import {parseUrl, recursiveParentElement} from '../../../common/commonUtils.js';
+import {recursiveParentElement} from '../../../common/commonUtils.js';
 import * as pb from '../../../workflows/proto/main_pb.js';
 
-import CRRunner from './actionRunners/replyWithCR.js';
+import AttributeRunner from './actionRunners/attribute.js';
 import ReadStateRunner from './actionRunners/readState.js';
+import CRRunner from './actionRunners/replyWithCR.js';
 import Thread from './models/thread.js';
 
 export default class WorkflowRunner {
@@ -16,6 +17,7 @@
     this._updateCallback = updateCallback;
 
     // Initialize action runners:
+    this._AttributeRunner = new AttributeRunner();
     this._CRRunner = new CRRunner();
     this._ReadStateRunner = new ReadStateRunner();
   }
@@ -55,6 +57,10 @@
 
   _runAction() {
     switch (this._currentAction?.getActionCase?.()) {
+      case pb.workflows.Action.ActionCase.ATTRIBUTE_ACTION:
+        return this._AttributeRunner.execute(
+            this._currentAction?.getAttributeAction?.(), this._currentThread);
+
       case pb.workflows.Action.ActionCase.REPLY_WITH_CR_ACTION:
         return this._CRRunner.execute(
             this._currentAction?.getReplyWithCrAction?.(), this._currentThread);
diff --git a/src/killSwitch/api_proto/kill_switch_grpc_web_pb.js b/src/killSwitch/api_proto/kill_switch_grpc_web_pb.js
index 47aeb29..b08346e 100644
--- a/src/killSwitch/api_proto/kill_switch_grpc_web_pb.js
+++ b/src/killSwitch/api_proto/kill_switch_grpc_web_pb.js
@@ -7,7 +7,7 @@
 // Code generated by protoc-gen-grpc-web. DO NOT EDIT.
 // versions:
 // 	protoc-gen-grpc-web v1.4.2
-// 	protoc              v3.21.12
+// 	protoc              v4.24.4
 // source: api_proto/kill_switch.proto
 
 
diff --git a/src/workflows/manager/components/ActionEditor.js b/src/workflows/manager/components/ActionEditor.js
index 9a57509..cdcb85a 100644
--- a/src/workflows/manager/components/ActionEditor.js
+++ b/src/workflows/manager/components/ActionEditor.js
@@ -1,5 +1,5 @@
+import './actions/Attribute.js';
 import './actions/ReplyWithCR.js';
-
 import '@material/mwc-circular-progress/mwc-circular-progress.js';
 
 import {html, LitElement, nothing} from 'lit';
@@ -61,6 +61,14 @@
           </wf-action-reply-with-cr>
         `;
 
+      case pb.workflows.Action.ActionCase.ATTRIBUTE_ACTION:
+        return html`
+          <wf-action-attribute
+              ?readOnly=${this.readOnly}
+              .action=${this.action.getAttributeAction()}>
+          </wf-action-attribute>
+        `;
+
       case pb.workflows.Action.ActionCase.MARK_AS_READ_ACTION:
       case pb.workflows.Action.ActionCase.MARK_AS_UNREAD_ACTION:
         return nothing;
@@ -216,6 +224,9 @@
       case pb.workflows.Action.ActionCase.REPLY_WITH_CR_ACTION:
         return this.renderRoot.querySelector('wf-action-reply-with-cr');
 
+      case pb.workflows.Action.ActionCase.ATTRIBUTE_ACTION:
+        return this.renderRoot.querySelector('wf-action-attribute');
+
       default:
         return null;
     }
diff --git a/src/workflows/manager/components/actions/Attribute.js b/src/workflows/manager/components/actions/Attribute.js
new file mode 100644
index 0000000..150e7da
--- /dev/null
+++ b/src/workflows/manager/components/actions/Attribute.js
@@ -0,0 +1,89 @@
+import '@material/web/select/outlined-select.js';
+import '@material/web/select/select-option.js';
+
+import {html, LitElement} from 'lit';
+import {createRef, ref} from 'lit/directives/ref.js';
+
+import {SHARED_MD3_STYLES} from '../../../../common/styles/md3.js';
+import * as pb from '../../../proto/main_pb.js';
+
+import {FORM_STYLES} from './common.js';
+
+const kHiddenActions = [
+  pb.workflows.Action.AttributeAction.AttributeAction.AA_NONE,
+  pb.workflows.Action.AttributeAction.AttributeAction.AA_SOFT_LOCK,
+  pb.workflows.Action.AttributeAction.AttributeAction.AA_UNSOFT_LOCK,
+];
+
+export default class WFActionAttribute extends LitElement {
+  static properties = {
+    action: {type: Object},
+    readOnly: {type: Boolean},
+  };
+
+  static styles = [
+    SHARED_MD3_STYLES,
+    FORM_STYLES,
+  ];
+
+  attributeActionRef = createRef();
+
+  constructor() {
+    super();
+    this.action = new pb.workflows.Action.AttributeAction;
+  }
+
+  render() {
+    return html`
+      <div class="form-line">
+        <md-outlined-select ${ref(this.attributeActionRef)}
+            required
+            label="Action"
+            value=${this.action}
+            ?disabled=${this.readOnly}
+            @change=${this._attributeActionChanged}>
+          ${this.renderAttributeActions()}
+        </md-outlined-select>
+      </div>
+    `;
+  }
+
+  renderAttributeActions() {
+    const attributeActions =
+        Object.entries(pb.workflows.Action.AttributeAction.AttributeAction);
+    return attributeActions.filter(([, id]) => !kHiddenActions.includes(id))
+        .map(([actionCodename, id]) => html`
+      <md-select-option value=${id}>
+        <div slot="headline">${actionCodename}</div>
+      </md-select-option>
+    `);
+  }
+
+  checkValidity() {
+    return this.attributeActionRef.value.reportValidity();
+  }
+
+  _dispatchUpdateEvent() {
+    // Request an update for this component
+    this.requestUpdate();
+
+    // Transmit to other components that the action has changed
+    const e =
+        new Event('attribute-action-updated', {bubbles: true, composed: true});
+    this.renderRoot.dispatchEvent(e);
+  }
+
+  _attributeActionChanged() {
+    this.attributeAction = this.attributeActionRef.value.value;
+  }
+
+  get attributeAction() {
+    return this.action.getAttributeAction();
+  }
+
+  set attributeAction(value) {
+    this.action.setAttributeAction(value);
+    this._dispatchUpdateEvent();
+  }
+}
+window.customElements.define('wf-action-attribute', WFActionAttribute);
diff --git a/src/workflows/manager/components/actions/ReplyWithCR.js b/src/workflows/manager/components/actions/ReplyWithCR.js
index 40a6b9c..e97f6f4 100644
--- a/src/workflows/manager/components/actions/ReplyWithCR.js
+++ b/src/workflows/manager/components/actions/ReplyWithCR.js
@@ -8,6 +8,7 @@
 
 import {SHARED_MD3_STYLES} from '../../../../common/styles/md3.js';
 import * as pb from '../../../proto/main_pb.js';
+import { FORM_STYLES } from './common.js';
 
 export default class WFActionReplyWithCR extends LitElement {
   static properties = {
@@ -18,15 +19,8 @@
 
   static styles = [
     SHARED_MD3_STYLES,
+    FORM_STYLES,
     css`
-      .form-line {
-        display: flex;
-        flex-direction: row;
-        align-items: center;
-        margin-block: 1em;
-        gap: .5rem;
-      }
-
       .select-cr-btn {
         --md-outlined-button-icon-size: 24px;
       }
diff --git a/src/workflows/manager/components/actions/common.js b/src/workflows/manager/components/actions/common.js
new file mode 100644
index 0000000..3d5be56
--- /dev/null
+++ b/src/workflows/manager/components/actions/common.js
@@ -0,0 +1,11 @@
+import {css} from 'lit';
+
+export const FORM_STYLES = css`
+  .form-line {
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+    margin-block: 1em;
+    gap: .5rem;
+  }
+`;
diff --git a/src/workflows/manager/shared/actions.js b/src/workflows/manager/shared/actions.js
index fcf12e1..ef23926 100644
--- a/src/workflows/manager/shared/actions.js
+++ b/src/workflows/manager/shared/actions.js
@@ -1,5 +1,7 @@
 import {css} from 'lit';
 
+import * as pb from '../../proto/main_pb.js';
+
 // TODO: remove this and substitute it with proper localization.
 export const kActionHeadings = {
   0: 'Unknown action',
@@ -7,7 +9,7 @@
   2: 'Move to a forum',
   3: 'Mark as duplicate of a thread',
   4: 'Unmark duplicate',
-  5: 'Change thread attributes',
+  5: 'Change thread attribute',
   6: 'Reply with canned response',
   16: 'Star/unstar thread',
   17: 'Subscribe/unsubscribe to thread',
@@ -17,7 +19,12 @@
   21: 'Mark as unread',
 };
 
-export const kSupportedActions = new Set([6, 20, 21]);
+export const kSupportedActions = new Set([
+  pb.workflows.Action.ActionCase.ATTRIBUTE_ACTION,
+  pb.workflows.Action.ActionCase.REPLY_WITH_CR_ACTION,
+  pb.workflows.Action.ActionCase.MARK_AS_READ_ACTION,
+  pb.workflows.Action.ActionCase.MARK_AS_UNREAD_ACTION,
+]);
 
 export const kActionStyles = css`
   .action {
diff --git a/src/workflows/proto/main.proto b/src/workflows/proto/main.proto
index 660be5e..dd10682 100644
--- a/src/workflows/proto/main.proto
+++ b/src/workflows/proto/main.proto
@@ -64,6 +64,11 @@
       AA_UNSET_TRENDING = 9;
       AA_SET_ISSUE_RESOLVED = 10;
       AA_UNSET_ISSUE_RESOLVED = 11;
+      AA_SOFT_LOCK = 12;
+      AA_UNSOFT_LOCK = 13;
+      AA_EXCLUDE_FROM_GOLDEN = 14;
+      AA_UNEXCLUDE_FROM_GOLDEN = 15;
+      AA_INCLUDE_IN_GOLDEN = 16;
     }
     AttributeAction attribute_action = 1;
   }
diff --git a/src/workflows/proto/main_pb.js b/src/workflows/proto/main_pb.js
index 961a61c..75ee13b 100644
--- a/src/workflows/proto/main_pb.js
+++ b/src/workflows/proto/main_pb.js
@@ -2195,7 +2195,12 @@
   AA_SET_TRENDING: 8,
   AA_UNSET_TRENDING: 9,
   AA_SET_ISSUE_RESOLVED: 10,
-  AA_UNSET_ISSUE_RESOLVED: 11
+  AA_UNSET_ISSUE_RESOLVED: 11,
+  AA_SOFT_LOCK: 12,
+  AA_UNSOFT_LOCK: 13,
+  AA_EXCLUDE_FROM_GOLDEN: 14,
+  AA_UNEXCLUDE_FROM_GOLDEN: 15,
+  AA_INCLUDE_IN_GOLDEN: 16
 };
 
 /**