Allow updating the banner via UI

Provide a UI to update the banner message and set its age. Only users
with the 'Update Banner' capability can see the button (next to the
settings icon) in the top right of the screen.

Screenshots: https://imgur.com/a/vhK7Vn0

Change-Id: Ie9526951f7574869f883491a28e73b5665479c91
diff --git a/gr-messageoftheday/gr-messageoftheday-edit.js b/gr-messageoftheday/gr-messageoftheday-edit.js
new file mode 100644
index 0000000..7928642
--- /dev/null
+++ b/gr-messageoftheday/gr-messageoftheday-edit.js
@@ -0,0 +1,153 @@
+/**
+ * @license
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {htmlTemplate} from './gr-messageoftheday-edit_html.js';
+
+class GrMessageOfTheDayEdit extends Polymer.Element {
+  static get is() {
+    return 'gr-messageoftheday-edit';
+  }
+
+  static get template() {
+    return htmlTemplate;
+  }
+
+  static get properties() {
+    return {
+      _message: {
+        type: String,
+        observer: '_messageChanged'
+      },
+      _expire_after_value: {
+        type: String,
+      },
+      _expire_after_unit: {
+        type: String,
+      },
+      _can_update: {
+        type: Boolean,
+        value: false,
+      },
+      _show_update_banner: {
+        type: Boolean,
+        value: false,
+      },
+    };
+  }
+
+  connectedCallback() {
+    super.connectedCallback();
+  }
+
+  ready() {
+    super.ready();
+    this._canUpdate();
+  }
+
+  _canUpdate() {
+    const endpoint = `/accounts/self/capabilities?q=messageoftheday-updateBanner`;
+    return this.plugin.restApi().get(endpoint).then(response => {
+      if (response && response['messageoftheday-updateBanner'] === true) {
+        this._can_update = true;
+        this._fetchMessage();
+      }
+    }).catch(error => {
+      console.error('Error checking updateBanner capability:', error);
+      this._can_update = false;
+    });
+  }
+
+  _fetchMessage() {
+    return this.plugin.restApi().get("/config/server/messageoftheday~message").then(response => {
+      if (response) {
+        this._message = response.html;
+      } else {
+        this._message = '';
+      }
+    }).catch(error => {
+      console.error('Error fetching message:', error);
+      this._message = '';
+    });
+  }
+
+  _saveMessage() {
+    const endpoint = `/config/server/messageoftheday~message`;
+    const payload = {
+      message: this._message
+    };
+    if (this._expire_after_value) {
+      payload.expires_at = this._convertToFormattedDate(
+          this._expire_after_value, this._expire_after_unit);
+    }
+
+    return this.plugin.restApi().post(endpoint, payload).then(
+        response => {
+          location.reload();
+        }
+    ).catch(error => {
+      console.error('Error saving message:', error);
+    });
+  }
+
+  _openDialog() {
+    this.$.message_dialog_overlay.show();
+    this.$.message_dialog.classList.toggle('invisible', false);
+  }
+
+  _closeDialog() {
+    this.$.message_dialog.classList.toggle('invisible', true);
+    this.$.message_dialog_overlay.close();
+  }
+
+  _messageChanged(newMessage) {
+    const messagePreview = this.shadowRoot.querySelector('#messagePreview');
+    if (messagePreview) {
+      messagePreview.innerHTML = newMessage;
+    }
+  }
+
+  _convertToFormattedDate(value, unit) {
+    let msToAdd = 0;
+    switch (unit) {
+      case 'm':
+        msToAdd = value * 60 * 1000;
+        break;
+      case 'h':
+        msToAdd = value * 60 * 60 * 1000;
+        break;
+      case 'd':
+        msToAdd = value * 24 * 60 * 60 * 1000;
+        break;
+      case 'w':
+        msToAdd = value * 7 * 24 * 60 * 60 * 1000;
+        break;
+    }
+
+    const now = new Date();
+    const future = new Date(now.getTime() + msToAdd);
+    const options = {
+      year: 'numeric',
+      month: '2-digit',
+      day: '2-digit',
+      hour: '2-digit',
+      minute: '2-digit',
+      timeZoneName: 'short'
+    };
+    return new Intl.DateTimeFormat('en-US', options).format(future);
+  }
+}
+
+customElements.define(GrMessageOfTheDayEdit.is, GrMessageOfTheDayEdit);
diff --git a/gr-messageoftheday/gr-messageoftheday-edit_html.js b/gr-messageoftheday/gr-messageoftheday-edit_html.js
new file mode 100644
index 0000000..faf0f32
--- /dev/null
+++ b/gr-messageoftheday/gr-messageoftheday-edit_html.js
@@ -0,0 +1,124 @@
+/**
+ * @license
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export const htmlTemplate = Polymer.html`
+  <style include="gr-modal-styles">
+    input, select {
+      background-color: var(--select-background-color);
+      color: var(--primary-text-color);
+      border: 1px solid var(--border-color);
+      border-radius: var(--border-radius);
+      padding: var(--spacing-s);
+      font: inherit;
+    }
+    iron-autogrow-textarea, #messagePreview {
+      background-color: var(--view-background-color);
+      color: var(--primary-text-color);
+      font: inherit;
+      width: 80ch;
+      height: 25ch;
+      border: 1px solid var(--border-color);
+      border-radius: var(--border-radius);
+      box-sizing: border-box;
+    }
+    iron-autogrow-textarea:focus {
+      border: 2px solid var(--input-focus-border-color);
+    }
+    #messagePreview {
+      background-color: var(--background-color-tertiary);
+      overflow-y: auto;
+    }
+    section {
+      margin-bottom: 1em;
+    }
+    paper-button {
+      elevation: 0;
+      margin-top: var(--spacing-s);
+    }
+    gr-icon {
+      --gr-button-text-color: var(--header-text-color);
+      color: var(--header-text-color);
+    }
+    .value {
+      display: flex;
+      flex-direction: column;
+      margin-bottom: 10px;
+    }
+    .value > * {
+      margin: 0;
+    }
+    .icon-button {
+      background: none;
+      box-shadow: none;
+      padding: 0;
+      min-width: 0;
+    }
+
+  </style>
+  <template is="dom-if" if="[[_can_update]]">
+    <paper-button class="icon-button" on-click="_openDialog">
+      <gr-icon icon="campaign" filled/>
+    </paper-button>
+  </template>
+  <dialog id="message_dialog_overlay" tabindex="-1">
+    <gr-dialog id="message_dialog" confirm-label="Save Message"
+        on-confirm="_saveMessage" on-cancel="_closeDialog">
+      <div class="header" slot="header">Set Banner Message</div>
+      <div class="main" slot="main">
+        <section>
+          <span class="title">Expire After:</span>
+          <span class="value">
+            <div style="display: flex; align-items: center; gap: 5px;">
+              <iron-input
+                bind-value="{{_expire_after_value}}"
+                placeholder="Enter Number">
+                <input
+                  is="iron-input"
+                  placeholder="Enter Number"
+                  bind-value="{{_expire_after_value}}"
+                />
+              </iron-input>
+              <gr-select bind-value="{{_expire_after_unit}}">
+                <select>
+                  <option value="m">minutes</option>
+                  <option value="h">hours</option>
+                  <option value="d">days</option>
+                  <option value="w">weeks</option>
+                </select>
+              </gr-select>
+            </div>
+          </span>
+        </section>
+        <section>
+          <span class="title">Message:</span>
+          <span class="value">
+            <iron-autogrow-textarea
+                on-keypress="_onKeyPressListener" class="text_area"
+                placeholder="Enter Message"
+                autocomplete="off" bind-value="{{_message}}"/>
+          </span>
+        </section>
+        <section>
+          <span class="title">Preview:</span>
+          <span class="value">
+            <div id="messagePreview" readonly>{{_message}}</div>
+          </span>
+        </section>
+      </div>
+    </gr-dialog>
+  </dialog>
+`;
diff --git a/gr-messageoftheday/plugin.js b/gr-messageoftheday/plugin.js
index 6385e04..475e28d 100644
--- a/gr-messageoftheday/plugin.js
+++ b/gr-messageoftheday/plugin.js
@@ -16,7 +16,9 @@
  */
 
 import './gr-messageoftheday-banner.js';
+import './gr-messageoftheday-edit.js';
 
 Gerrit.install(plugin => {
+  plugin.registerCustomComponent('header-top-right', 'gr-messageoftheday-edit');
   plugin.registerCustomComponent('banner', 'gr-messageoftheday-banner');
 });
diff --git a/src/main/resources/Documentation/about.md b/src/main/resources/Documentation/about.md
index 46e0ca9..6ca4ebe 100644
--- a/src/main/resources/Documentation/about.md
+++ b/src/main/resources/Documentation/about.md
@@ -7,3 +7,7 @@
 be redisplayed on the web UI the next day at 00:00. The next day is
 calculated with respect to the client. Note that, after dismissal, the
 banner will be redisplayed (before the next day) if its message is updated.
+
+The plugin also provides the ability to set the message via the UI. An
+'announce' icon will be rendered in the top right of the header, which
+will be visible only to users who have the 'updateBanner' capability.