From a98bf488843c2c2ae099fd89bd57a42fff69bf07 Mon Sep 17 00:00:00 2001
From: Tusooa Zhu <tusooa@kazv.moe>
Date: Thu, 17 Mar 2022 12:59:10 -0400
Subject: [PATCH 01/21] Add announcement display with placeholder messages

---
 src/boot/routes.js                            |  2 +
 src/components/announcement/announcement.js   | 13 ++++++
 src/components/announcement/announcement.vue  | 19 +++++++++
 .../announcements_page/announcements_page.js  | 42 +++++++++++++++++++
 .../announcements_page/announcements_page.vue | 21 ++++++++++
 src/components/nav_panel/nav_panel.js         |  6 ++-
 src/components/nav_panel/nav_panel.vue        | 12 ++++++
 src/components/side_drawer/side_drawer.vue    | 13 ++++++
 8 files changed, 126 insertions(+), 2 deletions(-)
 create mode 100644 src/components/announcement/announcement.js
 create mode 100644 src/components/announcement/announcement.vue
 create mode 100644 src/components/announcements_page/announcements_page.js
 create mode 100644 src/components/announcements_page/announcements_page.vue

diff --git a/src/boot/routes.js b/src/boot/routes.js
index 905ffe41..e9cdb0c5 100644
--- a/src/boot/routes.js
+++ b/src/boot/routes.js
@@ -20,6 +20,7 @@ import ShoutPanel from 'components/shout_panel/shout_panel.vue'
 import WhoToFollow from 'components/who_to_follow/who_to_follow.vue'
 import About from 'components/about/about.vue'
 import RemoteUserResolver from 'components/remote_user_resolver/remote_user_resolver.vue'
+import AnnouncementsPage from 'components/announcements_page/announcements_page.vue'
 
 export default (store) => {
   const validateAuthenticatedRoute = (to, from, next) => {
@@ -69,6 +70,7 @@ export default (store) => {
     { name: 'search', path: '/search', component: Search, props: (route) => ({ query: route.query.query }) },
     { name: 'who-to-follow', path: '/who-to-follow', component: WhoToFollow, beforeEnter: validateAuthenticatedRoute },
     { name: 'about', path: '/about', component: About },
+    { name: 'announcements', path: '/announcements', component: AnnouncementsPage },
     { name: 'user-profile', path: '/:_(users)?/:name', component: UserProfile }
   ]
 
diff --git a/src/components/announcement/announcement.js b/src/components/announcement/announcement.js
new file mode 100644
index 00000000..595f3b4e
--- /dev/null
+++ b/src/components/announcement/announcement.js
@@ -0,0 +1,13 @@
+
+const Announcement = {
+  props: {
+    announcement: Object
+  },
+  computed: {
+    content () {
+      return this.announcement.content
+    }
+  }
+}
+
+export default Announcement
diff --git a/src/components/announcement/announcement.vue b/src/components/announcement/announcement.vue
new file mode 100644
index 00000000..c31328ee
--- /dev/null
+++ b/src/components/announcement/announcement.vue
@@ -0,0 +1,19 @@
+<template>
+  <div class="announcement">
+    <rich-content :html="content" />
+  </div>
+</template>
+
+<script src="./announcement.js"></script>
+
+<style lang="scss">
+@import "../../variables";
+
+.announcement {
+  border-bottom-width: 1px;
+  border-bottom-style: solid;
+  border-bottom-color: var(--border, $fallback--border);
+  border-radius: 0;
+  padding: var(--status-margin, $status-margin);
+}
+</style>
diff --git a/src/components/announcements_page/announcements_page.js b/src/components/announcements_page/announcements_page.js
new file mode 100644
index 00000000..716468e6
--- /dev/null
+++ b/src/components/announcements_page/announcements_page.js
@@ -0,0 +1,42 @@
+import Announcement from '../announcement/announcement.vue'
+
+const AnnouncementsPage = {
+  components: {
+    Announcement
+  },
+  computed: {
+    announcements () {
+      return [{
+        "id": "8",
+        "content": "<p>Looks like there was an issue processing audio attachments without embedded art since yesterday due to an experimental new feature. That issue has now been fixed, so you may see older posts with audio from other servers pop up in your feeds now as they are being finally properly processed. Sorry!</p>",
+        "starts_at": null,
+        "ends_at": null,
+        "all_day": false,
+        "published_at": "2020-07-03T01:27:38.726Z",
+        "updated_at": "2020-07-03T01:27:38.752Z",
+        "read": true,
+        "mentions": [],
+        "statuses": [],
+        "tags": [],
+        "emojis": [],
+        "reactions": []
+      }, {
+        "id": "8",
+        "content": "<p>Looks like there was an issue processing audio attachments without embedded art since yesterday due to an experimental new feature. That issue has now been fixed, so you may see older posts with audio from other servers pop up in your feeds now as they are being finally properly processed. Sorry!</p>",
+        "starts_at": null,
+        "ends_at": null,
+        "all_day": false,
+        "published_at": "2020-07-03T01:27:38.726Z",
+        "updated_at": "2020-07-03T01:27:38.752Z",
+        "read": true,
+        "mentions": [],
+        "statuses": [],
+        "tags": [],
+        "emojis": [],
+        "reactions": []
+      }]
+    }
+  }
+}
+
+export default AnnouncementsPage
diff --git a/src/components/announcements_page/announcements_page.vue b/src/components/announcements_page/announcements_page.vue
new file mode 100644
index 00000000..16b2f955
--- /dev/null
+++ b/src/components/announcements_page/announcements_page.vue
@@ -0,0 +1,21 @@
+<template>
+  <div class="panel panel-default announcements-page">
+    <div class="panel-heading">
+      <span>
+        {{ $t('announcements.page_header') }}
+      </span>
+    </div>
+    <div class="panel-body">
+      <section
+        v-for="announcement in announcements"
+        :key="announcement.id"
+      >
+        <announcement
+          :announcement="announcement"
+        />
+      </section>
+    </div>
+  </div>
+</template>
+
+<script src="./announcements_page.js"></script>
diff --git a/src/components/nav_panel/nav_panel.js b/src/components/nav_panel/nav_panel.js
index 37bcb409..1064e341 100644
--- a/src/components/nav_panel/nav_panel.js
+++ b/src/components/nav_panel/nav_panel.js
@@ -12,7 +12,8 @@ import {
   faComments,
   faBell,
   faInfoCircle,
-  faStream
+  faStream,
+  faBullhorn
 } from '@fortawesome/free-solid-svg-icons'
 
 library.add(
@@ -25,7 +26,8 @@ library.add(
   faComments,
   faBell,
   faInfoCircle,
-  faStream
+  faStream,
+  faBullhorn
 )
 
 const NavPanel = {
diff --git a/src/components/nav_panel/nav_panel.vue b/src/components/nav_panel/nav_panel.vue
index 7ae7b1d6..36cf25d1 100644
--- a/src/components/nav_panel/nav_panel.vue
+++ b/src/components/nav_panel/nav_panel.vue
@@ -85,6 +85,18 @@
             />{{ $t("nav.about") }}
           </router-link>
         </li>
+        <li>
+          <router-link
+            class="menu-item"
+            :to="{ name: 'announcements' }"
+          >
+            <FAIcon
+              fixed-width
+              class="fa-scale-110"
+              icon="bullhorn"
+            />{{ $t('nav.announcements') }}
+          </router-link>
+        </li>
       </ul>
     </div>
   </div>
diff --git a/src/components/side_drawer/side_drawer.vue b/src/components/side_drawer/side_drawer.vue
index dd88de7d..d886e027 100644
--- a/src/components/side_drawer/side_drawer.vue
+++ b/src/components/side_drawer/side_drawer.vue
@@ -179,6 +179,19 @@
             /> {{ $t("nav.administration") }}
           </a>
         </li>
+        <li
+          @click="toggleDrawer"
+        >
+          <router-link
+            :to="{ name: 'announcements' }"
+          >
+            <FAIcon
+              fixed-width
+              class="fa-scale-110 fa-old-padding"
+              icon="bullhorn"
+            /> {{ $t("nav.announcements") }}
+          </router-link>
+        </li>
         <li
           v-if="currentUser"
           @click="toggleDrawer"

From bd1c4595ab0f9d11ca29832317701a69bd36170b Mon Sep 17 00:00:00 2001
From: Tusooa Zhu <tusooa@kazv.moe>
Date: Thu, 17 Mar 2022 14:01:45 -0400
Subject: [PATCH 02/21] Fetch real data from backend

---
 src/components/announcement/announcement.js   | 10 ++++
 src/components/announcement/announcement.vue  | 24 +++++++-
 .../announcements_page/announcements_page.js  | 33 ++--------
 src/main.js                                   |  4 +-
 src/modules/announcements.js                  | 60 +++++++++++++++++++
 src/services/api/api.service.js               | 18 +++++-
 6 files changed, 117 insertions(+), 32 deletions(-)
 create mode 100644 src/modules/announcements.js

diff --git a/src/components/announcement/announcement.js b/src/components/announcement/announcement.js
index 595f3b4e..9e043e48 100644
--- a/src/components/announcement/announcement.js
+++ b/src/components/announcement/announcement.js
@@ -6,6 +6,16 @@ const Announcement = {
   computed: {
     content () {
       return this.announcement.content
+    },
+    isRead () {
+      return this.announcement.read
+    }
+  },
+  methods: {
+    markAsRead () {
+      if (!this.isRead) {
+        return this.$store.dispatch('markAnnouncementAsRead', this.announcement.id)
+      }
     }
   }
 }
diff --git a/src/components/announcement/announcement.vue b/src/components/announcement/announcement.vue
index c31328ee..d8591468 100644
--- a/src/components/announcement/announcement.vue
+++ b/src/components/announcement/announcement.vue
@@ -1,6 +1,24 @@
 <template>
   <div class="announcement">
-    <rich-content :html="content" />
+    <div class="heading">
+      <h4>{{ $t('announcements.title') }}</h4>
+    </div>
+    <div class="body">
+      <rich-content
+        :html="content"
+        :emoji="announcement.emojis"
+        :handle-links="true"
+      />
+    </div>
+    <div class="footer">
+      <button
+        class="btn button-default"
+        :class="{ toggled: isRead }"
+        @click="markAsRead"
+      >
+        {{ $t('announcements.mark_as_read_action') }}
+      </button>
+    </div>
   </div>
 </template>
 
@@ -15,5 +33,9 @@
   border-bottom-color: var(--border, $fallback--border);
   border-radius: 0;
   padding: var(--status-margin, $status-margin);
+
+  .heading, .body {
+    margin-bottom: var(--status-margin, $status-margin);
+  }
 }
 </style>
diff --git a/src/components/announcements_page/announcements_page.js b/src/components/announcements_page/announcements_page.js
index 716468e6..a2a218fd 100644
--- a/src/components/announcements_page/announcements_page.js
+++ b/src/components/announcements_page/announcements_page.js
@@ -4,37 +4,12 @@ const AnnouncementsPage = {
   components: {
     Announcement
   },
+  mounted () {
+    this.$store.dispatch('fetchAnnouncements')
+  },
   computed: {
     announcements () {
-      return [{
-        "id": "8",
-        "content": "<p>Looks like there was an issue processing audio attachments without embedded art since yesterday due to an experimental new feature. That issue has now been fixed, so you may see older posts with audio from other servers pop up in your feeds now as they are being finally properly processed. Sorry!</p>",
-        "starts_at": null,
-        "ends_at": null,
-        "all_day": false,
-        "published_at": "2020-07-03T01:27:38.726Z",
-        "updated_at": "2020-07-03T01:27:38.752Z",
-        "read": true,
-        "mentions": [],
-        "statuses": [],
-        "tags": [],
-        "emojis": [],
-        "reactions": []
-      }, {
-        "id": "8",
-        "content": "<p>Looks like there was an issue processing audio attachments without embedded art since yesterday due to an experimental new feature. That issue has now been fixed, so you may see older posts with audio from other servers pop up in your feeds now as they are being finally properly processed. Sorry!</p>",
-        "starts_at": null,
-        "ends_at": null,
-        "all_day": false,
-        "published_at": "2020-07-03T01:27:38.726Z",
-        "updated_at": "2020-07-03T01:27:38.752Z",
-        "read": true,
-        "mentions": [],
-        "statuses": [],
-        "tags": [],
-        "emojis": [],
-        "reactions": []
-      }]
+      return this.$store.state.announcements.announcements
     }
   }
 }
diff --git a/src/main.js b/src/main.js
index eacd554c..94af4b9c 100644
--- a/src/main.js
+++ b/src/main.js
@@ -19,6 +19,7 @@ import reportsModule from './modules/reports.js'
 import pollsModule from './modules/polls.js'
 import postStatusModule from './modules/postStatus.js'
 import chatsModule from './modules/chats.js'
+import announcementsModule from './modules/announcements.js'
 
 import { createI18n } from 'vue-i18n'
 
@@ -81,7 +82,8 @@ const persistedStateOptions = {
       reports: reportsModule,
       polls: pollsModule,
       postStatus: postStatusModule,
-      chats: chatsModule
+      chats: chatsModule,
+      announcements: announcementsModule
     },
     plugins,
     strict: false // Socket modifies itself, let's ignore this for now.
diff --git a/src/modules/announcements.js b/src/modules/announcements.js
new file mode 100644
index 00000000..2ab2600c
--- /dev/null
+++ b/src/modules/announcements.js
@@ -0,0 +1,60 @@
+import { set } from 'vue'
+
+const FETCH_ANNOUNCEMENT_INTERVAL_MS = 1000 * 60 * 5
+
+export const defaultState = {
+  announcements: [],
+  fetchAnnouncementsTimer: undefined
+}
+
+export const mutations = {
+  setAnnouncements (state, announcements) {
+    set(state, 'announcements', announcements)
+  },
+  setAnnouncementRead (state, { id, read }) {
+    if (!state.announcements[id]) {
+      return
+    }
+
+    set(state.announcements[id], 'read', read)
+  },
+  setFetchAnnouncementsTimer (state, timer) {
+    set(state, 'fetchAnnouncementsTimer', announcements)
+  }
+}
+
+const announcements = {
+  state: defaultState,
+  mutations,
+  actions: {
+    fetchAnnouncements (store) {
+      return store.rootState.api.backendInteractor.fetchAnnouncements()
+        .then(announcements => {
+          store.commit('setAnnouncements', announcements)
+        })
+    },
+    markAnnouncementAsRead (store, id) {
+      return store.rootState.api.backendInteractor.dismissAnnouncement({ id })
+        .then(() => {
+          store.commit('setAnnouncementRead', { id, read: true })
+        })
+    },
+    startFetchingAnnouncements (store) {
+      if (store.state.fetchAnnouncementsTimer) {
+        return
+      }
+
+      const interval = setInterval(() => store.dispatch('fetchAnnouncements'), FETCH_ANNOUNCEMENT_INTERVAL_MS)
+      store.commit('setFetchAnnouncementsTimer', interval)
+
+      return store.dispatch('fetchAnnouncements')
+    },
+    stopFetchingAnnouncements (store) {
+      const interval = store.state.fetchAnnouncementsTimer
+      store.commit('setFetchAnnouncementsTimer', undefined)
+      clearInterval(interval)
+    }
+  }
+}
+
+export default announcements
diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js
index 436b8b0a..78a7276a 100644
--- a/src/services/api/api.service.js
+++ b/src/services/api/api.service.js
@@ -79,6 +79,8 @@ const MASTODON_USER_SEARCH_URL = '/api/v1/accounts/search'
 const MASTODON_DOMAIN_BLOCKS_URL = '/api/v1/domain_blocks'
 const MASTODON_STREAMING = '/api/v1/streaming'
 const MASTODON_KNOWN_DOMAIN_LIST_URL = '/api/v1/instance/peers'
+const MASTODON_ANNOUNCEMENTS_URL = '/api/v1/announcements'
+const MASTODON_ANNOUNCEMENTS_DISMISS_URL = id => `/api/v1/announcements/${id}/dismiss`
 const PLEROMA_EMOJI_REACTIONS_URL = id => `/api/v1/pleroma/statuses/${id}/reactions`
 const PLEROMA_EMOJI_REACT_URL = (id, emoji) => `/api/v1/pleroma/statuses/${id}/reactions/${emoji}`
 const PLEROMA_EMOJI_UNREACT_URL = (id, emoji) => `/api/v1/pleroma/statuses/${id}/reactions/${emoji}`
@@ -1081,6 +1083,18 @@ const dismissNotification = ({ credentials, id }) => {
   })
 }
 
+const fetchAnnouncements = ({ credentials }) => {
+  return promisedRequest({ url: MASTODON_ANNOUNCEMENTS_URL, credentials })
+}
+
+const dismissAnnouncement = ({ id, credentials }) => {
+  return promisedRequest({
+    url: MASTODON_ANNOUNCEMENTS_DISMISS_URL(id),
+    credentials,
+    method: 'POST'
+  })
+}
+
 export const getMastodonSocketURI = ({ credentials, stream, args = {} }) => {
   return Object.entries({
     ...(credentials
@@ -1351,7 +1365,9 @@ const apiService = {
   chatMessages,
   sendChatMessage,
   readChat,
-  deleteChatMessage
+  deleteChatMessage,
+  fetchAnnouncements,
+  dismissAnnouncement
 }
 
 export default apiService

From 8262bb560539e61776bbff320f627a0c293a2732 Mon Sep 17 00:00:00 2001
From: Tusooa Zhu <tusooa@kazv.moe>
Date: Thu, 17 Mar 2022 14:02:21 -0400
Subject: [PATCH 03/21] Add English translations for announcements

---
 src/i18n/en.json | 8 +++++++-
 1 file changed, 7 insertions(+), 1 deletion(-)

diff --git a/src/i18n/en.json b/src/i18n/en.json
index f8336e5c..e99039f8 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -32,6 +32,11 @@
     },
     "staff": "Staff"
   },
+  "announcements": {
+    "page_header": "Announcements",
+    "title": "Announcement",
+    "mark_as_read_action": "Mark as read"
+  },
   "shoutbox": {
     "title": "Shoutbox"
   },
@@ -146,7 +151,8 @@
     "who_to_follow": "Who to follow",
     "preferences": "Preferences",
     "timelines": "Timelines",
-    "chats": "Chats"
+    "chats": "Chats",
+    "announcements": "Announcements"
   },
   "notifications": {
     "broken_favorite": "Unknown status, searching for it…",

From b2943d2c66d7470a94ea1933d9e48b54bc4d44d1 Mon Sep 17 00:00:00 2001
From: Tusooa Zhu <tusooa@kazv.moe>
Date: Thu, 17 Mar 2022 14:54:52 -0400
Subject: [PATCH 04/21] Implement posting announcements

---
 .../announcements_page/announcements_page.js  | 28 +++++++
 .../announcements_page/announcements_page.vue | 76 +++++++++++++++++++
 src/modules/announcements.js                  |  6 ++
 src/services/api/api.service.js               | 27 ++++++-
 4 files changed, 136 insertions(+), 1 deletion(-)

diff --git a/src/components/announcements_page/announcements_page.js b/src/components/announcements_page/announcements_page.js
index a2a218fd..ec038361 100644
--- a/src/components/announcements_page/announcements_page.js
+++ b/src/components/announcements_page/announcements_page.js
@@ -1,16 +1,44 @@
+import { mapState } from 'vuex'
 import Announcement from '../announcement/announcement.vue'
 
 const AnnouncementsPage = {
   components: {
     Announcement
   },
+  data () {
+    return {
+      newAnnouncement: {
+        content: ''
+      },
+      posting: false,
+      error: undefined
+    }
+  },
   mounted () {
     this.$store.dispatch('fetchAnnouncements')
   },
   computed: {
+    ...mapState({
+      currentUser: state => state.users.currentUser
+    }),
     announcements () {
       return this.$store.state.announcements.announcements
     }
+  },
+  methods: {
+    postAnnouncement () {
+      this.posting = true
+      this.$store.dispatch('postAnnouncement', this.newAnnouncement)
+        .catch(error => {
+          this.error = error.error
+        })
+        .finally(() => {
+          this.posting = false
+        })
+    },
+    clearError () {
+      this.error = undefined
+    }
   }
 }
 
diff --git a/src/components/announcements_page/announcements_page.vue b/src/components/announcements_page/announcements_page.vue
index 16b2f955..1b3bd578 100644
--- a/src/components/announcements_page/announcements_page.vue
+++ b/src/components/announcements_page/announcements_page.vue
@@ -6,6 +6,51 @@
       </span>
     </div>
     <div class="panel-body">
+      <section
+        v-if="currentUser && currentUser.role === 'admin'"
+      >
+        <div class="post-form">
+          <div class="heading">
+            <h4>{{ $t('announcements.post_form_header') }}</h4>
+          </div>
+          <div class="body">
+            <textarea
+              ref="textarea"
+              v-model="newAnnouncement.content"
+              class="post-textarea"
+              rows="1"
+              cols="1"
+              :placeholder="$t('announcements.post_placeholder')"
+              :disabled="posting"
+            />
+          </div>
+          <div class="footer">
+            <button
+              class="btn button-default post-button"
+              :disabled="posting"
+              @click.prevent="postAnnouncement"
+            >
+              {{ $t('announcements.post_action') }}
+            </button>
+            <div
+              v-if="error"
+              class="alert error"
+            >
+              {{ $t('announcements.post_error', { error }) }}
+              <button
+                class="button-unstyled"
+                @click="clearError"
+              >
+                <FAIcon
+                  class="fa-scale-110 fa-old-padding"
+                  icon="times"
+                  :title="$t('announcements.close_error')"
+                />
+              </button>
+            </div>
+          </div>
+        </div>
+      </section>
       <section
         v-for="announcement in announcements"
         :key="announcement.id"
@@ -19,3 +64,34 @@
 </template>
 
 <script src="./announcements_page.js"></script>
+
+<style lang="scss">
+@import "../../variables";
+
+.announcements-page {
+  .post-form {
+    padding: var(--status-margin, $status-margin);
+
+    .heading, .body {
+      margin-bottom: var(--status-margin, $status-margin);
+    }
+
+    .body {
+      display: flex;
+      align-items: stretch;
+      flex-direction: column;
+    }
+
+    .post-textarea {
+      resize: vertical;
+      height: 10em;
+      overflow: none;
+      box-sizing: content-box;
+    }
+
+    .post-button {
+      min-width: 10em;
+    }
+  }
+}
+</style>
diff --git a/src/modules/announcements.js b/src/modules/announcements.js
index 2ab2600c..76cea154 100644
--- a/src/modules/announcements.js
+++ b/src/modules/announcements.js
@@ -53,6 +53,12 @@ const announcements = {
       const interval = store.state.fetchAnnouncementsTimer
       store.commit('setFetchAnnouncementsTimer', undefined)
       clearInterval(interval)
+    },
+    postAnnouncement (store, { content, startsAt, endsAt, allDay }) {
+      return store.rootState.api.backendInteractor.postAnnouncement({ content, startsAt, endsAt, allDay })
+        .then(() => {
+          return store.dispatch('fetchAnnouncements')
+        })
     }
   }
 }
diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js
index 78a7276a..497a9d9f 100644
--- a/src/services/api/api.service.js
+++ b/src/services/api/api.service.js
@@ -89,6 +89,7 @@ const PLEROMA_CHAT_URL = id => `/api/v1/pleroma/chats/by-account-id/${id}`
 const PLEROMA_CHAT_MESSAGES_URL = id => `/api/v1/pleroma/chats/${id}/messages`
 const PLEROMA_CHAT_READ_URL = id => `/api/v1/pleroma/chats/${id}/read`
 const PLEROMA_DELETE_CHAT_MESSAGE_URL = (chatId, messageId) => `/api/v1/pleroma/chats/${chatId}/messages/${messageId}`
+const PLEROMA_POST_ANNOUNCEMENT_URL = '/api/v1/pleroma/admin/announcements'
 
 const oldfetch = window.fetch
 
@@ -1095,6 +1096,29 @@ const dismissAnnouncement = ({ id, credentials }) => {
   })
 }
 
+const postAnnouncement = ({ credentials, content, startsAt, endsAt, allDay }) => {
+  const payload = { content }
+
+  if (typeof startsAt !== 'undefined') {
+    payload['starts_at'] = startsAt
+  }
+
+  if (typeof endsAt !== 'undefined') {
+    payload['ends_at'] = endsAt
+  }
+
+  if (typeof allDay !== 'undefined') {
+    payload['all_day'] = allDay
+  }
+
+  return promisedRequest({
+    url: PLEROMA_POST_ANNOUNCEMENT_URL,
+    credentials,
+    method: 'POST',
+    payload
+  })
+}
+
 export const getMastodonSocketURI = ({ credentials, stream, args = {} }) => {
   return Object.entries({
     ...(credentials
@@ -1367,7 +1391,8 @@ const apiService = {
   readChat,
   deleteChatMessage,
   fetchAnnouncements,
-  dismissAnnouncement
+  dismissAnnouncement,
+  postAnnouncement
 }
 
 export default apiService

From 9d0ed5a9e6a2e1cedd4897bc21c4e9483ffe4851 Mon Sep 17 00:00:00 2001
From: Tusooa Zhu <tusooa@kazv.moe>
Date: Thu, 17 Mar 2022 14:55:03 -0400
Subject: [PATCH 05/21] Add English translations for posting announcements

---
 src/i18n/en.json | 7 ++++++-
 1 file changed, 6 insertions(+), 1 deletion(-)

diff --git a/src/i18n/en.json b/src/i18n/en.json
index e99039f8..4d223d27 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -35,7 +35,12 @@
   "announcements": {
     "page_header": "Announcements",
     "title": "Announcement",
-    "mark_as_read_action": "Mark as read"
+    "mark_as_read_action": "Mark as read",
+    "post_form_header": "Post announcement",
+    "post_placeholder": "Type your announcement content here...",
+    "post_action": "Post",
+    "post_error": "Error: {error}",
+    "close_error": "Close"
   },
   "shoutbox": {
     "title": "Shoutbox"

From 0daa23eaf7ee6241e900958f9bba605ebdf08cba Mon Sep 17 00:00:00 2001
From: Tusooa Zhu <tusooa@kazv.moe>
Date: Thu, 17 Mar 2022 15:07:04 -0400
Subject: [PATCH 06/21] Implement deleting announcement

---
 src/components/announcement/announcement.js  |  7 +++++++
 src/components/announcement/announcement.vue | 18 ++++++++++++++++++
 src/modules/announcements.js                 |  6 ++++++
 src/services/api/api.service.js              | 12 +++++++++++-
 4 files changed, 42 insertions(+), 1 deletion(-)

diff --git a/src/components/announcement/announcement.js b/src/components/announcement/announcement.js
index 9e043e48..b1f5ee77 100644
--- a/src/components/announcement/announcement.js
+++ b/src/components/announcement/announcement.js
@@ -1,9 +1,13 @@
+import { mapState } from 'vuex'
 
 const Announcement = {
   props: {
     announcement: Object
   },
   computed: {
+    ...mapState({
+      currentUser: state => state.users.currentUser
+    }),
     content () {
       return this.announcement.content
     },
@@ -16,6 +20,9 @@ const Announcement = {
       if (!this.isRead) {
         return this.$store.dispatch('markAnnouncementAsRead', this.announcement.id)
       }
+    },
+    deleteAnnouncement () {
+      return this.$store.dispatch('deleteAnnouncement', this.announcement.id)
     }
   }
 }
diff --git a/src/components/announcement/announcement.vue b/src/components/announcement/announcement.vue
index d8591468..d6f35b9f 100644
--- a/src/components/announcement/announcement.vue
+++ b/src/components/announcement/announcement.vue
@@ -12,12 +12,20 @@
     </div>
     <div class="footer">
       <button
+        v-if="currentUser"
         class="btn button-default"
         :class="{ toggled: isRead }"
         @click="markAsRead"
       >
         {{ $t('announcements.mark_as_read_action') }}
       </button>
+      <button
+        v-if="currentUser && currentUser.role === 'admin'"
+        class="btn button-default"
+        @click="deleteAnnouncement"
+      >
+        {{ $t('announcements.delete_action') }}
+      </button>
     </div>
   </div>
 </template>
@@ -37,5 +45,15 @@
   .heading, .body {
     margin-bottom: var(--status-margin, $status-margin);
   }
+
+  .footer {
+    display: flex;
+    flex-direction: row;
+    justify-content: space-around;
+
+    .btn {
+      min-width: 10em;
+    }
+  }
 }
 </style>
diff --git a/src/modules/announcements.js b/src/modules/announcements.js
index 76cea154..ddc51c33 100644
--- a/src/modules/announcements.js
+++ b/src/modules/announcements.js
@@ -59,6 +59,12 @@ const announcements = {
         .then(() => {
           return store.dispatch('fetchAnnouncements')
         })
+    },
+    deleteAnnouncement (store, id) {
+      return store.rootState.api.backendInteractor.deleteAnnouncement({ id })
+        .then(() => {
+          return store.dispatch('fetchAnnouncements')
+        })
     }
   }
 }
diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js
index 497a9d9f..b6b44872 100644
--- a/src/services/api/api.service.js
+++ b/src/services/api/api.service.js
@@ -90,6 +90,7 @@ const PLEROMA_CHAT_MESSAGES_URL = id => `/api/v1/pleroma/chats/${id}/messages`
 const PLEROMA_CHAT_READ_URL = id => `/api/v1/pleroma/chats/${id}/read`
 const PLEROMA_DELETE_CHAT_MESSAGE_URL = (chatId, messageId) => `/api/v1/pleroma/chats/${chatId}/messages/${messageId}`
 const PLEROMA_POST_ANNOUNCEMENT_URL = '/api/v1/pleroma/admin/announcements'
+const PLEROMA_DELETE_ANNOUNCEMENT_URL = id => `/api/v1/pleroma/admin/announcements/${id}`
 
 const oldfetch = window.fetch
 
@@ -1119,6 +1120,14 @@ const postAnnouncement = ({ credentials, content, startsAt, endsAt, allDay }) =>
   })
 }
 
+const deleteAnnouncement = ({ id, credentials }) => {
+  return promisedRequest({
+    url: PLEROMA_DELETE_ANNOUNCEMENT_URL(id),
+    credentials,
+    method: 'DELETE'
+  })
+}
+
 export const getMastodonSocketURI = ({ credentials, stream, args = {} }) => {
   return Object.entries({
     ...(credentials
@@ -1392,7 +1401,8 @@ const apiService = {
   deleteChatMessage,
   fetchAnnouncements,
   dismissAnnouncement,
-  postAnnouncement
+  postAnnouncement,
+  deleteAnnouncement
 }
 
 export default apiService

From 75b3721dc7eda42e33fe178dcc5869279fcfb785 Mon Sep 17 00:00:00 2001
From: Tusooa Zhu <tusooa@kazv.moe>
Date: Thu, 17 Mar 2022 15:09:06 -0400
Subject: [PATCH 07/21] Add English translation for deleting announcements

---
 src/i18n/en.json | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/src/i18n/en.json b/src/i18n/en.json
index 4d223d27..1fea3eea 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -40,7 +40,8 @@
     "post_placeholder": "Type your announcement content here...",
     "post_action": "Post",
     "post_error": "Error: {error}",
-    "close_error": "Close"
+    "close_error": "Close",
+    "delete_action": "Delete"
   },
   "shoutbox": {
     "title": "Shoutbox"

From 7ecf62c405d917a655c352866296574c6d4d91f9 Mon Sep 17 00:00:00 2001
From: Tusooa Zhu <tusooa@kazv.moe>
Date: Thu, 17 Mar 2022 15:45:45 -0400
Subject: [PATCH 08/21] Allow posting announcements with other metadata

---
 .../announcements_page/announcements_page.js  | 14 ++++++++--
 .../announcements_page/announcements_page.vue | 26 +++++++++++++++++++
 src/services/api/api.service.js               |  4 +--
 3 files changed, 40 insertions(+), 4 deletions(-)

diff --git a/src/components/announcements_page/announcements_page.js b/src/components/announcements_page/announcements_page.js
index ec038361..30759533 100644
--- a/src/components/announcements_page/announcements_page.js
+++ b/src/components/announcements_page/announcements_page.js
@@ -1,14 +1,19 @@
 import { mapState } from 'vuex'
 import Announcement from '../announcement/announcement.vue'
+import Checkbox from '../checkbox/checkbox.vue'
 
 const AnnouncementsPage = {
   components: {
-    Announcement
+    Announcement,
+    Checkbox
   },
   data () {
     return {
       newAnnouncement: {
-        content: ''
+        content: '',
+        startsAt: undefined,
+        endsAt: undefined,
+        allDay: false
       },
       posting: false,
       error: undefined
@@ -29,6 +34,11 @@ const AnnouncementsPage = {
     postAnnouncement () {
       this.posting = true
       this.$store.dispatch('postAnnouncement', this.newAnnouncement)
+        .then(() => {
+          this.newAnnouncement.content = ''
+          this.startsAt = undefined
+          this.endsAt = undefined
+        })
         .catch(error => {
           this.error = error.error
         })
diff --git a/src/components/announcements_page/announcements_page.vue b/src/components/announcements_page/announcements_page.vue
index 1b3bd578..e81edee1 100644
--- a/src/components/announcements_page/announcements_page.vue
+++ b/src/components/announcements_page/announcements_page.vue
@@ -23,6 +23,29 @@
               :placeholder="$t('announcements.post_placeholder')"
               :disabled="posting"
             />
+            <span class="announcement-metadata">
+              <label for="announcement-start-time">{{ $t('announcements.start_time_prompt') }}</label>
+              <input
+                id="announcement-start-time"
+                v-model="newAnnouncement.startsAt"
+                :type="newAnnouncement.allDay ? 'date' : 'datetime-local'"
+              >
+            </span>
+            <span class="announcement-metadata">
+              <label for="announcement-end-time">{{ $t('announcements.end_time_prompt') }}</label>
+              <input
+                id="announcement-end-time"
+                v-model="newAnnouncement.endsAt"
+                :type="newAnnouncement.allDay ? 'date' : 'datetime-local'"
+              >
+            </span>
+            <span class="announcement-metadata">
+              <Checkbox
+                id="announcement-all-day"
+                v-model="newAnnouncement.allDay"
+              />
+              <label for="announcement-all-day">{{ $t('announcements.all_day_prompt') }}</label>
+            </span>
           </div>
           <div class="footer">
             <button
@@ -80,6 +103,9 @@
       display: flex;
       align-items: stretch;
       flex-direction: column;
+      .announcement-metadata {
+        margin-top: 0.5em;
+      }
     }
 
     .post-textarea {
diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js
index b6b44872..bcc4e7b7 100644
--- a/src/services/api/api.service.js
+++ b/src/services/api/api.service.js
@@ -1101,11 +1101,11 @@ const postAnnouncement = ({ credentials, content, startsAt, endsAt, allDay }) =>
   const payload = { content }
 
   if (typeof startsAt !== 'undefined') {
-    payload['starts_at'] = startsAt
+    payload['starts_at'] = new Date(startsAt).toISOString()
   }
 
   if (typeof endsAt !== 'undefined') {
-    payload['ends_at'] = endsAt
+    payload['ends_at'] = new Date(endsAt).toISOString()
   }
 
   if (typeof allDay !== 'undefined') {

From 732008c9bc41acb6f2218c5df3bdeb801d65dc5c Mon Sep 17 00:00:00 2001
From: Tusooa Zhu <tusooa@kazv.moe>
Date: Thu, 17 Mar 2022 15:46:18 -0400
Subject: [PATCH 09/21] Add English translation for posting announcement
 metadata

---
 src/i18n/en.json | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/src/i18n/en.json b/src/i18n/en.json
index 1fea3eea..0320769b 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -41,7 +41,10 @@
     "post_action": "Post",
     "post_error": "Error: {error}",
     "close_error": "Close",
-    "delete_action": "Delete"
+    "delete_action": "Delete",
+    "start_time_prompt": "Start time: ",
+    "end_time_prompt": "End time: ",
+    "all_day_prompt": "This is an all-day event"
   },
   "shoutbox": {
     "title": "Shoutbox"

From b9b242c489561b346b97ac7923082454b44ba516 Mon Sep 17 00:00:00 2001
From: Tusooa Zhu <tusooa@kazv.moe>
Date: Thu, 17 Mar 2022 16:51:32 -0400
Subject: [PATCH 10/21] Show announcement dates

---
 src/components/announcement/announcement.js   | 35 ++++++++++++
 src/components/announcement/announcement.vue  | 54 +++++++++++++------
 .../announcement_editor.js                    | 13 +++++
 .../announcement_editor.vue                   | 52 ++++++++++++++++++
 .../announcements_page/announcements_page.js  |  4 +-
 .../announcements_page/announcements_page.vue | 41 +-------------
 src/modules/announcements.js                  |  7 ++-
 src/services/api/api.service.js               |  8 ++-
 8 files changed, 156 insertions(+), 58 deletions(-)
 create mode 100644 src/components/announcement_editor/announcement_editor.js
 create mode 100644 src/components/announcement_editor/announcement_editor.vue

diff --git a/src/components/announcement/announcement.js b/src/components/announcement/announcement.js
index b1f5ee77..309eecea 100644
--- a/src/components/announcement/announcement.js
+++ b/src/components/announcement/announcement.js
@@ -1,6 +1,21 @@
 import { mapState } from 'vuex'
+import AnnouncementEditor from '../announcement_editor/announcement_editor.vue'
+import localeService from '../../services/locale/locale.service.js'
 
 const Announcement = {
+  components: {
+    AnnouncementEditor
+  },
+  data () {
+    return {
+      editing: false,
+      newAnnouncement: {
+        content: '',
+        startsAt: undefined,
+        endsAt: undefined
+      }
+    }
+  },
   props: {
     announcement: Object
   },
@@ -13,6 +28,22 @@ const Announcement = {
     },
     isRead () {
       return this.announcement.read
+    },
+    startsAt () {
+      const time = this.announcement['starts_at']
+      if (!time) {
+        return
+      }
+
+      return this.formatTimeOrDate(time, localeService.internalToBrowserLocale(this.$i18n.locale))
+    },
+    endsAt () {
+      const time = this.announcement['ends_at']
+      if (!time) {
+        return
+      }
+
+      return this.formatTimeOrDate(time, localeService.internalToBrowserLocale(this.$i18n.locale))
     }
   },
   methods: {
@@ -23,6 +54,10 @@ const Announcement = {
     },
     deleteAnnouncement () {
       return this.$store.dispatch('deleteAnnouncement', this.announcement.id)
+    },
+    formatTimeOrDate (time, locale) {
+      const d = new Date(time)
+      return this.announcement['all_day'] ? d.toLocaleDateString(locale) : d.toLocaleString(locale)
     }
   }
 }
diff --git a/src/components/announcement/announcement.vue b/src/components/announcement/announcement.vue
index d6f35b9f..5ddeebf5 100644
--- a/src/components/announcement/announcement.vue
+++ b/src/components/announcement/announcement.vue
@@ -5,27 +5,42 @@
     </div>
     <div class="body">
       <rich-content
+        v-if="!editing"
         :html="content"
         :emoji="announcement.emojis"
         :handle-links="true"
       />
+      <announcement-editor
+        v-else
+        :announcement="newAnnouncement"
+      />
     </div>
     <div class="footer">
-      <button
-        v-if="currentUser"
-        class="btn button-default"
-        :class="{ toggled: isRead }"
-        @click="markAsRead"
-      >
-        {{ $t('announcements.mark_as_read_action') }}
-      </button>
-      <button
-        v-if="currentUser && currentUser.role === 'admin'"
-        class="btn button-default"
-        @click="deleteAnnouncement"
-      >
-        {{ $t('announcements.delete_action') }}
-      </button>
+      <div class="times">
+        <span v-if="startsAt">
+          {{ $t('announcements.start_time_display', { time: startsAt }) }}
+        </span>
+        <span v-if="endsAt">
+          {{ $t('announcements.end_time_display', { time: endsAt }) }}
+        </span>
+      </div>
+      <div class="actions">
+        <button
+          v-if="currentUser"
+          class="btn button-default"
+          :class="{ toggled: isRead }"
+          @click="markAsRead"
+        >
+          {{ $t('announcements.mark_as_read_action') }}
+        </button>
+        <button
+          v-if="currentUser && currentUser.role === 'admin'"
+          class="btn button-default"
+          @click="deleteAnnouncement"
+        >
+          {{ $t('announcements.delete_action') }}
+        </button>
+      </div>
     </div>
   </div>
 </template>
@@ -47,6 +62,15 @@
   }
 
   .footer {
+    display: flex;
+    flex-direction: column;
+    .times {
+      display: flex;
+      flex-direction: column;
+    }
+  }
+
+  .footer .actions {
     display: flex;
     flex-direction: row;
     justify-content: space-around;
diff --git a/src/components/announcement_editor/announcement_editor.js b/src/components/announcement_editor/announcement_editor.js
new file mode 100644
index 00000000..79a03afe
--- /dev/null
+++ b/src/components/announcement_editor/announcement_editor.js
@@ -0,0 +1,13 @@
+import Checkbox from '../checkbox/checkbox.vue'
+
+const AnnouncementEditor = {
+  components: {
+    Checkbox
+  },
+  props: {
+    announcement: Object,
+    disabled: Boolean
+  }
+}
+
+export default AnnouncementEditor
diff --git a/src/components/announcement_editor/announcement_editor.vue b/src/components/announcement_editor/announcement_editor.vue
new file mode 100644
index 00000000..e2418b8d
--- /dev/null
+++ b/src/components/announcement_editor/announcement_editor.vue
@@ -0,0 +1,52 @@
+<template>
+  <div class="announcement-editor">
+    <textarea
+      ref="textarea"
+      v-model="announcement.content"
+      class="post-textarea"
+      rows="1"
+      cols="1"
+      :placeholder="$t('announcements.post_placeholder')"
+      :disabled="disabled"
+    />
+    <span class="announcement-metadata">
+      <label for="announcement-start-time">{{ $t('announcements.start_time_prompt') }}</label>
+      <input
+        id="announcement-start-time"
+        v-model="announcement.startsAt"
+        :type="announcement.allDay ? 'date' : 'datetime-local'"
+        :disabled="disabled"
+      >
+    </span>
+    <span class="announcement-metadata">
+      <label for="announcement-end-time">{{ $t('announcements.end_time_prompt') }}</label>
+      <input
+        id="announcement-end-time"
+        v-model="announcement.endsAt"
+        :type="announcement.allDay ? 'date' : 'datetime-local'"
+        :disabled="disabled"
+      >
+    </span>
+    <span class="announcement-metadata">
+      <Checkbox
+        id="announcement-all-day"
+        v-model="announcement.allDay"
+        :disabled="disabled"
+      />
+      <label for="announcement-all-day">{{ $t('announcements.all_day_prompt') }}</label>
+    </span>
+  </div>
+</template>
+
+<script src="./announcement_editor.js"></script>
+
+<style lang="scss">
+    .announcement-editor {
+      display: flex;
+      align-items: stretch;
+      flex-direction: column;
+      .announcement-metadata {
+        margin-top: 0.5em;
+      }
+    }
+</style>
diff --git a/src/components/announcements_page/announcements_page.js b/src/components/announcements_page/announcements_page.js
index 30759533..0bb4892e 100644
--- a/src/components/announcements_page/announcements_page.js
+++ b/src/components/announcements_page/announcements_page.js
@@ -1,11 +1,11 @@
 import { mapState } from 'vuex'
 import Announcement from '../announcement/announcement.vue'
-import Checkbox from '../checkbox/checkbox.vue'
+import AnnouncementEditor from '../announcement_editor/announcement_editor.vue'
 
 const AnnouncementsPage = {
   components: {
     Announcement,
-    Checkbox
+    AnnouncementEditor
   },
   data () {
     return {
diff --git a/src/components/announcements_page/announcements_page.vue b/src/components/announcements_page/announcements_page.vue
index e81edee1..54307c4d 100644
--- a/src/components/announcements_page/announcements_page.vue
+++ b/src/components/announcements_page/announcements_page.vue
@@ -14,38 +14,10 @@
             <h4>{{ $t('announcements.post_form_header') }}</h4>
           </div>
           <div class="body">
-            <textarea
-              ref="textarea"
-              v-model="newAnnouncement.content"
-              class="post-textarea"
-              rows="1"
-              cols="1"
-              :placeholder="$t('announcements.post_placeholder')"
+            <announcement-editor
+              :announcement="newAnnouncement"
               :disabled="posting"
             />
-            <span class="announcement-metadata">
-              <label for="announcement-start-time">{{ $t('announcements.start_time_prompt') }}</label>
-              <input
-                id="announcement-start-time"
-                v-model="newAnnouncement.startsAt"
-                :type="newAnnouncement.allDay ? 'date' : 'datetime-local'"
-              >
-            </span>
-            <span class="announcement-metadata">
-              <label for="announcement-end-time">{{ $t('announcements.end_time_prompt') }}</label>
-              <input
-                id="announcement-end-time"
-                v-model="newAnnouncement.endsAt"
-                :type="newAnnouncement.allDay ? 'date' : 'datetime-local'"
-              >
-            </span>
-            <span class="announcement-metadata">
-              <Checkbox
-                id="announcement-all-day"
-                v-model="newAnnouncement.allDay"
-              />
-              <label for="announcement-all-day">{{ $t('announcements.all_day_prompt') }}</label>
-            </span>
           </div>
           <div class="footer">
             <button
@@ -99,15 +71,6 @@
       margin-bottom: var(--status-margin, $status-margin);
     }
 
-    .body {
-      display: flex;
-      align-items: stretch;
-      flex-direction: column;
-      .announcement-metadata {
-        margin-top: 0.5em;
-      }
-    }
-
     .post-textarea {
       resize: vertical;
       height: 10em;
diff --git a/src/modules/announcements.js b/src/modules/announcements.js
index ddc51c33..a42acf93 100644
--- a/src/modules/announcements.js
+++ b/src/modules/announcements.js
@@ -28,7 +28,12 @@ const announcements = {
   mutations,
   actions: {
     fetchAnnouncements (store) {
-      return store.rootState.api.backendInteractor.fetchAnnouncements()
+      const currentUser = store.rootState.users.currentUser
+      const isAdmin = currentUser && currentUser.role === 'admin'
+
+      return (isAdmin
+        ? store.rootState.api.backendInteractor.adminFetchAnnouncements()
+        : store.rootState.api.backendInteractor.fetchAnnouncements())
         .then(announcements => {
           store.commit('setAnnouncements', announcements)
         })
diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js
index bcc4e7b7..b24c688c 100644
--- a/src/services/api/api.service.js
+++ b/src/services/api/api.service.js
@@ -89,6 +89,7 @@ const PLEROMA_CHAT_URL = id => `/api/v1/pleroma/chats/by-account-id/${id}`
 const PLEROMA_CHAT_MESSAGES_URL = id => `/api/v1/pleroma/chats/${id}/messages`
 const PLEROMA_CHAT_READ_URL = id => `/api/v1/pleroma/chats/${id}/read`
 const PLEROMA_DELETE_CHAT_MESSAGE_URL = (chatId, messageId) => `/api/v1/pleroma/chats/${chatId}/messages/${messageId}`
+const PLEROMA_ANNOUNCEMENTS_URL = '/api/v1/pleroma/admin/announcements'
 const PLEROMA_POST_ANNOUNCEMENT_URL = '/api/v1/pleroma/admin/announcements'
 const PLEROMA_DELETE_ANNOUNCEMENT_URL = id => `/api/v1/pleroma/admin/announcements/${id}`
 
@@ -1085,6 +1086,10 @@ const dismissNotification = ({ credentials, id }) => {
   })
 }
 
+const adminFetchAnnouncements = ({ credentials }) => {
+  return promisedRequest({ url: PLEROMA_ANNOUNCEMENTS_URL, credentials })
+}
+
 const fetchAnnouncements = ({ credentials }) => {
   return promisedRequest({ url: MASTODON_ANNOUNCEMENTS_URL, credentials })
 }
@@ -1402,7 +1407,8 @@ const apiService = {
   fetchAnnouncements,
   dismissAnnouncement,
   postAnnouncement,
-  deleteAnnouncement
+  deleteAnnouncement,
+  adminFetchAnnouncements
 }
 
 export default apiService

From f6dd02c286d8eeeb00738241c37ed18cbf7304d6 Mon Sep 17 00:00:00 2001
From: Tusooa Zhu <tusooa@kazv.moe>
Date: Thu, 17 Mar 2022 16:51:47 -0400
Subject: [PATCH 11/21] Add English translation for announcement dates

---
 src/i18n/en.json | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/src/i18n/en.json b/src/i18n/en.json
index 0320769b..3431423d 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -44,7 +44,9 @@
     "delete_action": "Delete",
     "start_time_prompt": "Start time: ",
     "end_time_prompt": "End time: ",
-    "all_day_prompt": "This is an all-day event"
+    "all_day_prompt": "This is an all-day event",
+    "start_time_display": "Starts at {time}",
+    "end_time_display": "Ends at {time}"
   },
   "shoutbox": {
     "title": "Shoutbox"

From a395297b82cb458e9e55c3c21b1e0a14162829b8 Mon Sep 17 00:00:00 2001
From: Tusooa Zhu <tusooa@kazv.moe>
Date: Thu, 17 Mar 2022 17:11:53 -0400
Subject: [PATCH 12/21] Allow editing announcements

---
 src/components/announcement/announcement.js   | 31 +++++++++++-
 src/components/announcement/announcement.vue  | 50 ++++++++++++++++++-
 .../announcement_editor.vue                   | 24 ++++++---
 .../announcements_page/announcements_page.vue |  7 ---
 src/modules/announcements.js                  |  6 +++
 src/services/api/api.service.js               | 23 +++++++--
 6 files changed, 118 insertions(+), 23 deletions(-)

diff --git a/src/components/announcement/announcement.js b/src/components/announcement/announcement.js
index 309eecea..253e2bb2 100644
--- a/src/components/announcement/announcement.js
+++ b/src/components/announcement/announcement.js
@@ -12,8 +12,10 @@ const Announcement = {
       newAnnouncement: {
         content: '',
         startsAt: undefined,
-        endsAt: undefined
-      }
+        endsAt: undefined,
+        allDay: undefined
+      },
+      editError: ''
     }
   },
   props: {
@@ -58,6 +60,31 @@ const Announcement = {
     formatTimeOrDate (time, locale) {
       const d = new Date(time)
       return this.announcement['all_day'] ? d.toLocaleDateString(locale) : d.toLocaleString(locale)
+    },
+    enterEditMode () {
+      this.newAnnouncement.content = this.announcement.pleroma['raw_content']
+      this.newAnnouncement.startsAt = this.announcement['starts_at']
+      this.newAnnouncement.endsAt = this.announcement['ends_at']
+      this.newAnnouncement.allDay = this.announcement['all_day']
+      this.editing = true
+    },
+    submitEdit () {
+      this.$store.dispatch('editAnnouncement', {
+        id: this.announcement.id,
+        ...this.newAnnouncement
+      })
+        .then(() => {
+          this.editing = false
+        })
+        .catch(error => {
+          this.editError = error.error
+        })
+    },
+    cancelEdit () {
+      this.editing = false
+    },
+    clearError () {
+      this.editError = undefined
     }
   }
 }
diff --git a/src/components/announcement/announcement.vue b/src/components/announcement/announcement.vue
index 5ddeebf5..1e4edcca 100644
--- a/src/components/announcement/announcement.vue
+++ b/src/components/announcement/announcement.vue
@@ -16,7 +16,10 @@
       />
     </div>
     <div class="footer">
-      <div class="times">
+      <div
+        v-if="!editing"
+        class="times"
+      >
         <span v-if="startsAt">
           {{ $t('announcements.start_time_display', { time: startsAt }) }}
         </span>
@@ -24,7 +27,10 @@
           {{ $t('announcements.end_time_display', { time: endsAt }) }}
         </span>
       </div>
-      <div class="actions">
+      <div
+        v-if="!editing"
+        class="actions"
+      >
         <button
           v-if="currentUser"
           class="btn button-default"
@@ -33,6 +39,13 @@
         >
           {{ $t('announcements.mark_as_read_action') }}
         </button>
+        <button
+          v-if="currentUser && currentUser.role === 'admin'"
+          class="btn button-default"
+          @click="enterEditMode"
+        >
+          {{ $t('announcements.edit_action') }}
+        </button>
         <button
           v-if="currentUser && currentUser.role === 'admin'"
           class="btn button-default"
@@ -41,6 +54,39 @@
           {{ $t('announcements.delete_action') }}
         </button>
       </div>
+      <div
+        v-else
+        class="actions"
+      >
+        <button
+          class="btn button-default"
+          @click="submitEdit"
+        >
+          {{ $t('announcements.submit_edit_action') }}
+        </button>
+        <button
+          class="btn button-default"
+          @click="cancelEdit"
+        >
+          {{ $t('announcements.cancel_edit_action') }}
+        </button>
+        <div
+          v-if="editing && editError"
+          class="alert error"
+        >
+          {{ $t('announcements.edit_error', { error }) }}
+          <button
+            class="button-unstyled"
+            @click="clearError"
+          >
+            <FAIcon
+              class="fa-scale-110 fa-old-padding"
+              icon="times"
+              :title="$t('announcements.close_error')"
+            />
+          </button>
+        </div>
+      </div>
     </div>
   </div>
 </template>
diff --git a/src/components/announcement_editor/announcement_editor.vue b/src/components/announcement_editor/announcement_editor.vue
index e2418b8d..0f29f9f7 100644
--- a/src/components/announcement_editor/announcement_editor.vue
+++ b/src/components/announcement_editor/announcement_editor.vue
@@ -41,12 +41,20 @@
 <script src="./announcement_editor.js"></script>
 
 <style lang="scss">
-    .announcement-editor {
-      display: flex;
-      align-items: stretch;
-      flex-direction: column;
-      .announcement-metadata {
-        margin-top: 0.5em;
-      }
-    }
+.announcement-editor {
+  display: flex;
+  align-items: stretch;
+  flex-direction: column;
+
+  .announcement-metadata {
+    margin-top: 0.5em;
+  }
+
+  .post-textarea {
+    resize: vertical;
+    height: 10em;
+    overflow: none;
+    box-sizing: content-box;
+  }
+}
 </style>
diff --git a/src/components/announcements_page/announcements_page.vue b/src/components/announcements_page/announcements_page.vue
index 54307c4d..b1489dec 100644
--- a/src/components/announcements_page/announcements_page.vue
+++ b/src/components/announcements_page/announcements_page.vue
@@ -71,13 +71,6 @@
       margin-bottom: var(--status-margin, $status-margin);
     }
 
-    .post-textarea {
-      resize: vertical;
-      height: 10em;
-      overflow: none;
-      box-sizing: content-box;
-    }
-
     .post-button {
       min-width: 10em;
     }
diff --git a/src/modules/announcements.js b/src/modules/announcements.js
index a42acf93..1d97c67c 100644
--- a/src/modules/announcements.js
+++ b/src/modules/announcements.js
@@ -65,6 +65,12 @@ const announcements = {
           return store.dispatch('fetchAnnouncements')
         })
     },
+    editAnnouncement (store, { id, content, startsAt, endsAt, allDay }) {
+      return store.rootState.api.backendInteractor.editAnnouncement({ id, content, startsAt, endsAt, allDay })
+        .then(() => {
+          return store.dispatch('fetchAnnouncements')
+        })
+    },
     deleteAnnouncement (store, id) {
       return store.rootState.api.backendInteractor.deleteAnnouncement({ id })
         .then(() => {
diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js
index b24c688c..db44e1c8 100644
--- a/src/services/api/api.service.js
+++ b/src/services/api/api.service.js
@@ -91,6 +91,7 @@ const PLEROMA_CHAT_READ_URL = id => `/api/v1/pleroma/chats/${id}/read`
 const PLEROMA_DELETE_CHAT_MESSAGE_URL = (chatId, messageId) => `/api/v1/pleroma/chats/${chatId}/messages/${messageId}`
 const PLEROMA_ANNOUNCEMENTS_URL = '/api/v1/pleroma/admin/announcements'
 const PLEROMA_POST_ANNOUNCEMENT_URL = '/api/v1/pleroma/admin/announcements'
+const PLEROMA_EDIT_ANNOUNCEMENT_URL = id => `/api/v1/pleroma/admin/announcements/${id}`
 const PLEROMA_DELETE_ANNOUNCEMENT_URL = id => `/api/v1/pleroma/admin/announcements/${id}`
 
 const oldfetch = window.fetch
@@ -1102,26 +1103,39 @@ const dismissAnnouncement = ({ id, credentials }) => {
   })
 }
 
-const postAnnouncement = ({ credentials, content, startsAt, endsAt, allDay }) => {
+const announcementToPayload = ({ content, startsAt, endsAt, allDay }) => {
   const payload = { content }
 
   if (typeof startsAt !== 'undefined') {
-    payload['starts_at'] = new Date(startsAt).toISOString()
+    payload['starts_at'] = startsAt ? new Date(startsAt).toISOString() : null
   }
 
   if (typeof endsAt !== 'undefined') {
-    payload['ends_at'] = new Date(endsAt).toISOString()
+    payload['ends_at'] = endsAt ? new Date(endsAt).toISOString() : null
   }
 
   if (typeof allDay !== 'undefined') {
     payload['all_day'] = allDay
   }
 
+  return payload
+}
+
+const postAnnouncement = ({ credentials, content, startsAt, endsAt, allDay }) => {
   return promisedRequest({
     url: PLEROMA_POST_ANNOUNCEMENT_URL,
     credentials,
     method: 'POST',
-    payload
+    payload: announcementToPayload({ content, startsAt, endsAt, allDay })
+  })
+}
+
+const editAnnouncement = ({ id, credentials, content, startsAt, endsAt, allDay }) => {
+  return promisedRequest({
+    url: PLEROMA_EDIT_ANNOUNCEMENT_URL(id),
+    credentials,
+    method: 'PATCH',
+    payload: announcementToPayload({ content, startsAt, endsAt, allDay })
   })
 }
 
@@ -1407,6 +1421,7 @@ const apiService = {
   fetchAnnouncements,
   dismissAnnouncement,
   postAnnouncement,
+  editAnnouncement,
   deleteAnnouncement,
   adminFetchAnnouncements
 }

From 93ed6a7d286ed28e0aa138dafeee4963f45cd39c Mon Sep 17 00:00:00 2001
From: Tusooa Zhu <tusooa@kazv.moe>
Date: Thu, 17 Mar 2022 17:14:52 -0400
Subject: [PATCH 13/21] Add English translations for editing announcements

---
 src/i18n/en.json | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/src/i18n/en.json b/src/i18n/en.json
index 3431423d..a387f629 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -46,7 +46,10 @@
     "end_time_prompt": "End time: ",
     "all_day_prompt": "This is an all-day event",
     "start_time_display": "Starts at {time}",
-    "end_time_display": "Ends at {time}"
+    "end_time_display": "Ends at {time}",
+    "edit_action": "Edit",
+    "submit_edit_action": "Submit",
+    "cancel_edit_action": "Cancel"
   },
   "shoutbox": {
     "title": "Shoutbox"

From fb02843bdb8304f1a1edf8a967f8ff4f04fe41c9 Mon Sep 17 00:00:00 2001
From: Tusooa Zhu <tusooa@kazv.moe>
Date: Thu, 17 Mar 2022 17:32:16 -0400
Subject: [PATCH 14/21] Mark inactive and active announcements for admin

---
 src/components/announcement/announcement.js  |  3 ++
 src/components/announcement/announcement.vue |  2 ++
 src/modules/announcements.js                 | 33 +++++++++++++++++---
 3 files changed, 33 insertions(+), 5 deletions(-)

diff --git a/src/components/announcement/announcement.js b/src/components/announcement/announcement.js
index 253e2bb2..893a07e0 100644
--- a/src/components/announcement/announcement.js
+++ b/src/components/announcement/announcement.js
@@ -46,6 +46,9 @@ const Announcement = {
       }
 
       return this.formatTimeOrDate(time, localeService.internalToBrowserLocale(this.$i18n.locale))
+    },
+    inactive () {
+      return this.announcement.inactive
     }
   },
   methods: {
diff --git a/src/components/announcement/announcement.vue b/src/components/announcement/announcement.vue
index 1e4edcca..f3b73d89 100644
--- a/src/components/announcement/announcement.vue
+++ b/src/components/announcement/announcement.vue
@@ -35,6 +35,8 @@
           v-if="currentUser"
           class="btn button-default"
           :class="{ toggled: isRead }"
+          :disabled="inactive"
+          :title="inactive ? $t('announcements.inactive_message') : ''"
           @click="markAsRead"
         >
           {{ $t('announcements.mark_as_read_action') }}
diff --git a/src/modules/announcements.js b/src/modules/announcements.js
index 1d97c67c..8470fb2a 100644
--- a/src/modules/announcements.js
+++ b/src/modules/announcements.js
@@ -12,11 +12,13 @@ export const mutations = {
     set(state, 'announcements', announcements)
   },
   setAnnouncementRead (state, { id, read }) {
-    if (!state.announcements[id]) {
+    const index = state.announcements.findIndex(a => a.id === id)
+
+    if (index < 0) {
       return
     }
 
-    set(state.announcements[id], 'read', read)
+    set(state.announcements[index], 'read', read)
   },
   setFetchAnnouncementsTimer (state, timer) {
     set(state, 'fetchAnnouncementsTimer', announcements)
@@ -31,9 +33,30 @@ const announcements = {
       const currentUser = store.rootState.users.currentUser
       const isAdmin = currentUser && currentUser.role === 'admin'
 
-      return (isAdmin
-        ? store.rootState.api.backendInteractor.adminFetchAnnouncements()
-        : store.rootState.api.backendInteractor.fetchAnnouncements())
+      const getAnnouncements = async () => {
+        if (!isAdmin) {
+          return store.rootState.api.backendInteractor.fetchAnnouncements()
+        }
+
+        const all = await store.rootState.api.backendInteractor.adminFetchAnnouncements()
+        const visible = await store.rootState.api.backendInteractor.fetchAnnouncements()
+        const visibleObject = visible.reduce((a, c) => {
+          a[c.id] = c
+          return a
+        }, {})
+
+        all.forEach(announcement => {
+          if (!visibleObject[announcement.id]) {
+            announcement.inactive = true
+          } else {
+            announcement.read = visibleObject[announcement.id].read
+          }
+        })
+
+        return all
+      }
+
+      return getAnnouncements()
         .then(announcements => {
           store.commit('setAnnouncements', announcements)
         })

From 60e2912005ad42b08cf29b213cfad122662d096d Mon Sep 17 00:00:00 2001
From: Tusooa Zhu <tusooa@kazv.moe>
Date: Thu, 17 Mar 2022 17:32:30 -0400
Subject: [PATCH 15/21] Add English translation for inactive announcement
 message

---
 src/i18n/en.json | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/src/i18n/en.json b/src/i18n/en.json
index a387f629..f936ab3a 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -49,7 +49,8 @@
     "end_time_display": "Ends at {time}",
     "edit_action": "Edit",
     "submit_edit_action": "Submit",
-    "cancel_edit_action": "Cancel"
+    "cancel_edit_action": "Cancel",
+    "inactive_message": "This announcement is inactive"
   },
   "shoutbox": {
     "title": "Shoutbox"

From bb2940d5410ca869f3255c7be4d983fae78e3f49 Mon Sep 17 00:00:00 2001
From: Tusooa Zhu <tusooa@kazv.moe>
Date: Thu, 17 Mar 2022 17:51:39 -0400
Subject: [PATCH 16/21] Show badges for unread announcements

---
 src/components/mobile_nav/mobile_nav.js       | 2 +-
 src/components/mobile_nav/mobile_nav.vue      | 2 +-
 src/components/nav_panel/nav_panel.js         | 2 +-
 src/components/nav_panel/nav_panel.vue        | 6 ++++++
 src/components/notifications/notifications.js | 4 ++--
 src/components/side_drawer/side_drawer.js     | 2 +-
 src/components/side_drawer/side_drawer.vue    | 6 ++++++
 src/modules/announcements.js                  | 9 +++++++++
 8 files changed, 27 insertions(+), 6 deletions(-)

diff --git a/src/components/mobile_nav/mobile_nav.js b/src/components/mobile_nav/mobile_nav.js
index 9e736cfb..fc88f07a 100644
--- a/src/components/mobile_nav/mobile_nav.js
+++ b/src/components/mobile_nav/mobile_nav.js
@@ -47,7 +47,7 @@ const MobileNav = {
     isChat () {
       return this.$route.name === 'chat'
     },
-    ...mapGetters(['unreadChatCount'])
+    ...mapGetters(['unreadChatCount', 'unreadAnnouncementCount'])
   },
   methods: {
     toggleMobileSidebar () {
diff --git a/src/components/mobile_nav/mobile_nav.vue b/src/components/mobile_nav/mobile_nav.vue
index f5279b3e..6a36f446 100644
--- a/src/components/mobile_nav/mobile_nav.vue
+++ b/src/components/mobile_nav/mobile_nav.vue
@@ -18,7 +18,7 @@
             icon="bars"
           />
           <div
-            v-if="unreadChatCount"
+            v-if="unreadChatCount || unreadAnnouncementCount"
             class="alert-dot"
           />
         </button>
diff --git a/src/components/nav_panel/nav_panel.js b/src/components/nav_panel/nav_panel.js
index 1064e341..0141845b 100644
--- a/src/components/nav_panel/nav_panel.js
+++ b/src/components/nav_panel/nav_panel.js
@@ -57,7 +57,7 @@ const NavPanel = {
       federating: state => state.instance.federating,
       pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable
     }),
-    ...mapGetters(['unreadChatCount'])
+    ...mapGetters(['unreadChatCount', 'unreadAnnouncementCount'])
   }
 }
 
diff --git a/src/components/nav_panel/nav_panel.vue b/src/components/nav_panel/nav_panel.vue
index 36cf25d1..1550ff94 100644
--- a/src/components/nav_panel/nav_panel.vue
+++ b/src/components/nav_panel/nav_panel.vue
@@ -95,6 +95,12 @@
               class="fa-scale-110"
               icon="bullhorn"
             />{{ $t('nav.announcements') }}
+            <span
+              v-if="unreadAnnouncementCount > 0"
+              class="badge badge-notification"
+            >
+              {{ unreadAnnouncementCount }}
+            </span>
           </router-link>
         </li>
       </ul>
diff --git a/src/components/notifications/notifications.js b/src/components/notifications/notifications.js
index c8f1ebcb..71600c82 100644
--- a/src/components/notifications/notifications.js
+++ b/src/components/notifications/notifications.js
@@ -60,7 +60,7 @@ const Notifications = {
       return this.unseenNotifications.length
     },
     unseenCountTitle () {
-      return this.unseenCount + (this.unreadChatCount)
+      return this.unseenCount + (this.unreadChatCount) + this.unreadAnnouncementCount
     },
     loading () {
       return this.$store.state.statuses.notifications.loading
@@ -68,7 +68,7 @@ const Notifications = {
     notificationsToDisplay () {
       return this.filteredNotifications.slice(0, this.unseenCount + this.seenToDisplayCount)
     },
-    ...mapGetters(['unreadChatCount'])
+    ...mapGetters(['unreadChatCount', 'unreadAnnouncementCount'])
   },
   watch: {
     unseenCountTitle (count) {
diff --git a/src/components/side_drawer/side_drawer.js b/src/components/side_drawer/side_drawer.js
index bad1806b..ee8c340e 100644
--- a/src/components/side_drawer/side_drawer.js
+++ b/src/components/side_drawer/side_drawer.js
@@ -86,7 +86,7 @@ const SideDrawer = {
     ...mapState({
       pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable
     }),
-    ...mapGetters(['unreadChatCount'])
+    ...mapGetters(['unreadChatCount', 'unreadAnnouncementCount'])
   },
   methods: {
     toggleDrawer () {
diff --git a/src/components/side_drawer/side_drawer.vue b/src/components/side_drawer/side_drawer.vue
index d886e027..7d783787 100644
--- a/src/components/side_drawer/side_drawer.vue
+++ b/src/components/side_drawer/side_drawer.vue
@@ -190,6 +190,12 @@
               class="fa-scale-110 fa-old-padding"
               icon="bullhorn"
             /> {{ $t("nav.announcements") }}
+            <span
+              v-if="unreadAnnouncementCount"
+              class="badge badge-notification"
+            >
+              {{ unreadAnnouncementCount }}
+            </span>
           </router-link>
         </li>
         <li
diff --git a/src/modules/announcements.js b/src/modules/announcements.js
index 8470fb2a..f04ba50e 100644
--- a/src/modules/announcements.js
+++ b/src/modules/announcements.js
@@ -25,9 +25,18 @@ export const mutations = {
   }
 }
 
+export const getters = {
+  unreadAnnouncementCount (state) {
+    return state.announcements.reduce((acc, cur) => {
+      return (!cur.inactive && !cur.read) ? acc + 1 : acc
+    }, 0)
+  }
+}
+
 const announcements = {
   state: defaultState,
   mutations,
+  getters,
   actions: {
     fetchAnnouncements (store) {
       const currentUser = store.rootState.users.currentUser

From 1fbfa6e75c404eb0d6e9116abd23a9c5589e3dc5 Mon Sep 17 00:00:00 2001
From: Tusooa Zhu <tusooa@kazv.moe>
Date: Thu, 17 Mar 2022 18:02:05 -0400
Subject: [PATCH 17/21] Start fetching announcements on page load

---
 src/boot/after_store.js | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/boot/after_store.js b/src/boot/after_store.js
index 76832708..535b867d 100644
--- a/src/boot/after_store.js
+++ b/src/boot/after_store.js
@@ -370,6 +370,7 @@ const afterStoreSetup = async ({ store, i18n }) => {
 
   // Start fetching things that don't need to block the UI
   store.dispatch('fetchMutes')
+  store.dispatch('startFetchingAnnouncements')
   getTOS({ store })
   getStickers({ store })
 

From 80dcee3b7b05cf00d923164b04fbef888be89c61 Mon Sep 17 00:00:00 2001
From: Tusooa Zhu <tusooa@kazv.moe>
Date: Thu, 17 Mar 2022 18:04:35 -0400
Subject: [PATCH 18/21] Do not count unread announcements for guests

---
 src/modules/announcements.js | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/src/modules/announcements.js b/src/modules/announcements.js
index f04ba50e..876bcdaf 100644
--- a/src/modules/announcements.js
+++ b/src/modules/announcements.js
@@ -26,7 +26,11 @@ export const mutations = {
 }
 
 export const getters = {
-  unreadAnnouncementCount (state) {
+  unreadAnnouncementCount (state, _getters, rootState) {
+    if (!rootState.users.currentUser) {
+      return 0
+    }
+
     return state.announcements.reduce((acc, cur) => {
       return (!cur.inactive && !cur.read) ? acc + 1 : acc
     }, 0)

From 3eb72861108b853e0eef9665ebc565e8fd76a7d7 Mon Sep 17 00:00:00 2001
From: Tusooa Zhu <tusooa@kazv.moe>
Date: Sun, 20 Mar 2022 13:26:57 -0400
Subject: [PATCH 19/21] Readablify unreadAnnouncementCount

---
 src/modules/announcements.js | 5 ++---
 1 file changed, 2 insertions(+), 3 deletions(-)

diff --git a/src/modules/announcements.js b/src/modules/announcements.js
index 876bcdaf..2606b786 100644
--- a/src/modules/announcements.js
+++ b/src/modules/announcements.js
@@ -31,9 +31,8 @@ export const getters = {
       return 0
     }
 
-    return state.announcements.reduce((acc, cur) => {
-      return (!cur.inactive && !cur.read) ? acc + 1 : acc
-    }, 0)
+    const unread = state.announcements.filter(announcement => !(announcement.inactive || announcement.read))
+    return unread.length
   }
 }
 

From 94e042e9526f82566c6e212d5aa6f6bf2d18c176 Mon Sep 17 00:00:00 2001
From: Tusooa Zhu <tusooa@kazv.moe>
Date: Sun, 20 Mar 2022 13:49:26 -0400
Subject: [PATCH 20/21] Change name for the announcement being edited to
 editedAnnouncement

---
 src/components/announcement/announcement.js  | 12 ++++++------
 src/components/announcement/announcement.vue |  2 +-
 2 files changed, 7 insertions(+), 7 deletions(-)

diff --git a/src/components/announcement/announcement.js b/src/components/announcement/announcement.js
index 893a07e0..caa0543e 100644
--- a/src/components/announcement/announcement.js
+++ b/src/components/announcement/announcement.js
@@ -9,7 +9,7 @@ const Announcement = {
   data () {
     return {
       editing: false,
-      newAnnouncement: {
+      editedAnnouncement: {
         content: '',
         startsAt: undefined,
         endsAt: undefined,
@@ -65,16 +65,16 @@ const Announcement = {
       return this.announcement['all_day'] ? d.toLocaleDateString(locale) : d.toLocaleString(locale)
     },
     enterEditMode () {
-      this.newAnnouncement.content = this.announcement.pleroma['raw_content']
-      this.newAnnouncement.startsAt = this.announcement['starts_at']
-      this.newAnnouncement.endsAt = this.announcement['ends_at']
-      this.newAnnouncement.allDay = this.announcement['all_day']
+      this.editedAnnouncement.content = this.announcement.pleroma['raw_content']
+      this.editedAnnouncement.startsAt = this.announcement['starts_at']
+      this.editedAnnouncement.endsAt = this.announcement['ends_at']
+      this.editedAnnouncement.allDay = this.announcement['all_day']
       this.editing = true
     },
     submitEdit () {
       this.$store.dispatch('editAnnouncement', {
         id: this.announcement.id,
-        ...this.newAnnouncement
+        ...this.editedAnnouncement
       })
         .then(() => {
           this.editing = false
diff --git a/src/components/announcement/announcement.vue b/src/components/announcement/announcement.vue
index f3b73d89..72d99d85 100644
--- a/src/components/announcement/announcement.vue
+++ b/src/components/announcement/announcement.vue
@@ -12,7 +12,7 @@
       />
       <announcement-editor
         v-else
-        :announcement="newAnnouncement"
+        :announcement="editedAnnouncement"
       />
     </div>
     <div class="footer">

From e7b75cf72c4b87885afe3d826117328540f70ad5 Mon Sep 17 00:00:00 2001
From: Tusooa Zhu <tusooa@kazv.moe>
Date: Fri, 29 Apr 2022 20:56:54 -0400
Subject: [PATCH 21/21] Port away from Vue2 Vue.set()

---
 src/modules/announcements.js | 8 +++-----
 1 file changed, 3 insertions(+), 5 deletions(-)

diff --git a/src/modules/announcements.js b/src/modules/announcements.js
index 2606b786..ab16626c 100644
--- a/src/modules/announcements.js
+++ b/src/modules/announcements.js
@@ -1,5 +1,3 @@
-import { set } from 'vue'
-
 const FETCH_ANNOUNCEMENT_INTERVAL_MS = 1000 * 60 * 5
 
 export const defaultState = {
@@ -9,7 +7,7 @@ export const defaultState = {
 
 export const mutations = {
   setAnnouncements (state, announcements) {
-    set(state, 'announcements', announcements)
+    state.announcements = announcements
   },
   setAnnouncementRead (state, { id, read }) {
     const index = state.announcements.findIndex(a => a.id === id)
@@ -18,10 +16,10 @@ export const mutations = {
       return
     }
 
-    set(state.announcements[index], 'read', read)
+    state.announcements[index].read = read
   },
   setFetchAnnouncementsTimer (state, timer) {
-    set(state, 'fetchAnnouncementsTimer', announcements)
+    state.fetchAnnouncementsTimer = timer
   }
 }