From b90ce7ebaf410d3b0025a586657c2888f5e6a248 Mon Sep 17 00:00:00 2001
From: xaralis <filip.varecha@fragaria.cz>
Date: Wed, 23 Dec 2020 11:51:00 +0100
Subject: [PATCH] feat: delete post, ban and reactions

---
 .env                                     |  1 +
 public/index.html                        | 15 ++++++
 src/App.jsx                              |  4 ++
 src/actions/announcements.js             | 16 ++++++
 src/actions/posts.js                     | 35 +++++++++----
 src/actions/users.js                     | 65 ++++++++++++++++++++++--
 src/containers/AnnoucementsContainer.jsx | 11 +---
 src/containers/PostsContainer.jsx        | 11 +---
 src/containers/StatsCard.jsx             | 43 ++++++++++++++++
 src/pages/Home.jsx                       | 21 ++++++--
 src/stores.js                            |  1 +
 src/utils.js                             | 36 ++++++++++++-
 src/ws/connection.js                     | 21 ++++++++
 src/ws/handlers/index.js                 |  5 ++
 src/ws/handlers/posts.js                 |  9 ++++
 src/ws/handlers/users.js                 | 17 +++++++
 typings/cf2021.d.ts                      |  6 +++
 17 files changed, 279 insertions(+), 38 deletions(-)
 create mode 100644 src/containers/StatsCard.jsx
 create mode 100644 src/ws/handlers/users.js

diff --git a/.env b/.env
index fd45135..daccf27 100644
--- a/.env
+++ b/.env
@@ -1,3 +1,4 @@
 REACT_APP_STYLEGUIDE_URL=http://localhost:3001
 REACT_APP_API_BASE_URL=https://cf2021.pirati.cz/api
 REACT_APP_WS_BASE_URL=wss://cf2021.pirati.cz/ws
+REACT_APP_MATOMO_ID=135
diff --git a/public/index.html b/public/index.html
index 9469ad4..3fae09c 100644
--- a/public/index.html
+++ b/public/index.html
@@ -49,4 +49,19 @@
       To create a production bundle, use `npm run build` or `yarn build`.
     -->
   </body>
+  <!-- Matomo -->
+  <script type="text/javascript">
+    var _paq = window._paq = window._paq || [];
+    /* tracker methods like "setCustomDimension" should be called before "trackPageView" */
+    _paq.push(['trackPageView']);
+    _paq.push(['enableLinkTracking']);
+    (function() {
+      var u="//matomo.pirati.cz/";
+      _paq.push(['setTrackerUrl', u+'matomo.php']);
+      _paq.push(['setSiteId', '%REACT_APP_MATOMO_ID%']);
+      var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
+      g.type='text/javascript'; g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s);
+    })();
+  </script>
+  <!-- End Matomo Code -->
 </html>
diff --git a/src/App.jsx b/src/App.jsx
index 6a9b209..bff1d19 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -4,6 +4,7 @@ import { KeycloakProvider } from "@react-keycloak/web";
 import * as Sentry from "@sentry/react";
 
 import { loadConfig } from "actions/global-info";
+import { loadMe } from "actions/users";
 import { initializeWSChannel } from "actions/ws";
 import Footer from "components/Footer";
 import Navbar from "components/Navbar";
@@ -50,6 +51,9 @@ const onKeycloakEvent = (event) => {
       };
     });
 
+    // Once base user details has been stored, load me details from API.
+    loadMe.run();
+
     PostStore.update((state) => {
       // Only display proposals verified by chairman to other users.
       state.filters.showPendingProposals = role === "chairman";
diff --git a/src/actions/announcements.js b/src/actions/announcements.js
index cb99ea5..647d5a6 100644
--- a/src/actions/announcements.js
+++ b/src/actions/announcements.js
@@ -6,7 +6,9 @@ import { fetch } from "api";
 import { AnnouncementStore } from "stores";
 import {
   announcementTypeMappingRev,
+  createSeenWriter,
   parseRawAnnouncement,
+  seenAnnouncementsLSKey,
   syncAnnoucementItemIds,
 } from "utils";
 
@@ -104,3 +106,17 @@ export const updateAnnouncementContent = createAsyncAction(
     }
   }
 );
+
+const { markSeen: storeSeen } = createSeenWriter(seenAnnouncementsLSKey);
+
+/**
+ * Mark down user saw this post already.
+ * @param {CF2021.Post} post
+ */
+export const markSeen = (post) => {
+  storeSeen(post.id);
+
+  AnnouncementStore.update((state) => {
+    state.items[post.id].seen = true;
+  });
+};
diff --git a/src/actions/posts.js b/src/actions/posts.js
index 2e6d123..3f97710 100644
--- a/src/actions/posts.js
+++ b/src/actions/posts.js
@@ -5,10 +5,12 @@ import { createAsyncAction, errorResult, successResult } from "pullstate";
 import { fetch } from "api";
 import { PostStore } from "stores";
 import {
+  createSeenWriter,
   filterPosts,
   parseRawPost,
   postsStateMappingRev,
   postsTypeMappingRev,
+  seenPostsLSKey,
 } from "utils";
 
 export const loadPosts = createAsyncAction(
@@ -135,16 +137,15 @@ export const hide = createAsyncAction(
    * @param {CF2021.Post} post
    */
   async (post) => {
-    return successResult(post);
-  },
-  {
-    postActionHook: ({ result }) => {
-      if (!result.error) {
-        PostStore.update((state) => {
-          state.items[result.payload.id].hidden = true;
-        });
-      }
-    },
+    try {
+      await fetch(`/posts/${post.id}`, {
+        method: "DELETE",
+        expectedStatus: 204,
+      });
+      return successResult();
+    } catch (err) {
+      return errorResult([], err.toString());
+    }
   }
 );
 
@@ -312,3 +313,17 @@ export const rejectProposalByChairman = createAsyncAction(
     },
   }
 );
+
+const { markSeen: storeSeen } = createSeenWriter(seenPostsLSKey);
+
+/**
+ * Mark down user saw this post already.
+ * @param {CF2021.Post} post
+ */
+export const markSeen = (post) => {
+  storeSeen(post.id);
+
+  PostStore.update((state) => {
+    state.items[post.id].seen = true;
+  });
+};
diff --git a/src/actions/users.js b/src/actions/users.js
index 7e3d1ae..7fe04d8 100644
--- a/src/actions/users.js
+++ b/src/actions/users.js
@@ -1,10 +1,69 @@
-import { createAsyncAction, successResult } from "pullstate";
+import { createAsyncAction, errorResult, successResult } from "pullstate";
+
+import { fetch } from "api";
+import { AuthStore } from "stores";
+
+export const loadMe = createAsyncAction(
+  /**
+   * @param {number} userId
+   */
+  async (user) => {
+    try {
+      const response = await fetch(`/users/me`, {
+        method: "GET",
+        expectedStatus: 200,
+      });
+      const data = await response.json();
+      return successResult(data);
+    } catch (err) {
+      return errorResult([], err.toString());
+    }
+  },
+  {
+    postActionHook: ({ result }) => {
+      if (!result.error) {
+        AuthStore.update((state) => {
+          if (state.user) {
+            state.user.id = result.payload.id;
+            state.user.group = result.payload.group;
+            state.user.isBanned = result.payload.is_banned;
+          }
+        });
+      }
+    },
+  }
+);
 
 export const ban = createAsyncAction(
   /**
    * @param {number} userId
    */
-  async (userId) => {
-    return successResult(userId);
+  async (user) => {
+    try {
+      await fetch(`/users/${user.id}/ban`, {
+        method: "PATCH",
+        expectedStatus: 204,
+      });
+      return successResult(user);
+    } catch (err) {
+      return errorResult([], err.toString());
+    }
+  }
+);
+
+export const removeBan = createAsyncAction(
+  /**
+   * @param {number} userId
+   */
+  async (user) => {
+    try {
+      await fetch(`/users/${user.id}/unban`, {
+        method: "PATCH",
+        expectedStatus: 204,
+      });
+      return successResult(user);
+    } catch (err) {
+      return errorResult([], err.toString());
+    }
   }
 );
diff --git a/src/containers/AnnoucementsContainer.jsx b/src/containers/AnnoucementsContainer.jsx
index cd8da9c..048e946 100644
--- a/src/containers/AnnoucementsContainer.jsx
+++ b/src/containers/AnnoucementsContainer.jsx
@@ -3,6 +3,7 @@ import React, { useCallback, useState } from "react";
 import {
   deleteAnnouncement,
   loadAnnouncements,
+  markSeen,
   updateAnnouncementContent,
 } from "actions/announcements";
 import AnnouncementEditModal from "components/annoucements/AnnouncementEditModal";
@@ -63,16 +64,6 @@ const AnnoucementsContainer = () => {
     setItemToEdit(null);
   }, [setItemToEdit]);
 
-  /**
-   * Mark down user saw this announcement already.
-   * @param {CF2021.Announcement} announcement
-   */
-  const markSeen = (announcement) => {
-    AnnouncementStore.update((state) => {
-      state.items[announcement.id].seen = true;
-    });
-  };
-
   return (
     <>
       {loadResult && loadResult.error && (
diff --git a/src/containers/PostsContainer.jsx b/src/containers/PostsContainer.jsx
index 907f7de..486eafe 100644
--- a/src/containers/PostsContainer.jsx
+++ b/src/containers/PostsContainer.jsx
@@ -10,6 +10,7 @@ import {
   hide,
   like,
   loadPosts,
+  markSeen,
   rejectProposal,
   rejectProposalByChairman,
 } from "actions/posts";
@@ -134,16 +135,6 @@ const PostsContainer = ({ className }) => {
     setUserToBan(post.author);
   };
 
-  /**
-   * Mark down user saw this post already.
-   * @param {CF2021.Post} post
-   */
-  const markSeen = (post) => {
-    PostStore.update((state) => {
-      state.items[post.id].seen = true;
-    });
-  };
-
   const sliceStart = (window.page - 1) * window.perPage;
   const sliceEnd = window.page * window.perPage;
   const windowItems = window.items.map((postId) => items[postId]);
diff --git a/src/containers/StatsCard.jsx b/src/containers/StatsCard.jsx
new file mode 100644
index 0000000..5b74d89
--- /dev/null
+++ b/src/containers/StatsCard.jsx
@@ -0,0 +1,43 @@
+import React from "react";
+import classNames from "classnames";
+
+import { Card, CardBody } from "components/cards";
+import { GlobalInfoStore } from "stores";
+
+const StatsCard = () => {
+  const { connectionState, onlineUsers } = GlobalInfoStore.useState();
+
+  const connectionIndicator = (
+    <div
+      className={classNames("inline-block rounded-full w-4 h-4 mr-2", {
+        "bg-green-400": connectionState === "connected",
+        "bg-red-600": connectionState === "offline",
+        "bg-yellow-200": connectionState === "connecting",
+      })}
+    />
+  );
+
+  return (
+    <Card>
+      <CardBody className="leading-normal">
+        <div className="flex justify-between">
+          <span>Stav vašeho připojení</span>
+          <div className="flex items-center">
+            {connectionIndicator}
+            <strong>
+              {connectionState === "connected" && "on-line"}
+              {connectionState === "offline" && "off-line"}
+              {connectionState === "connecting" && "připojování"}
+            </strong>
+          </div>
+        </div>
+        <div className="flex justify-between">
+          <span>Počet on-line účastníků</span>
+          <strong>{onlineUsers}</strong>
+        </div>
+      </CardBody>
+    </Card>
+  );
+};
+
+export default StatsCard;
diff --git a/src/pages/Home.jsx b/src/pages/Home.jsx
index 4d3dc7e..a623482 100644
--- a/src/pages/Home.jsx
+++ b/src/pages/Home.jsx
@@ -9,6 +9,7 @@ import {
 } from "actions/program";
 import Button from "components/Button";
 import { DropdownMenu, DropdownMenuItem } from "components/dropdown-menu";
+import ErrorMessage from "components/ErrorMessage";
 import ModalConfirm from "components/modals/ModalConfirm";
 import ProgramEntryEditModal from "components/program/ProgramEntryEditModal";
 import AddAnnouncementForm from "containers/AddAnnouncementForm";
@@ -16,6 +17,7 @@ import AddPostForm from "containers/AddPostForm";
 import AnnouncementsContainer from "containers/AnnoucementsContainer";
 import PostFilters from "containers/PostFilters";
 import PostsContainer from "containers/PostsContainer";
+import StatsCard from "containers/StatsCard";
 import { useActionConfirm } from "hooks";
 import { AuthStore, GlobalInfoStore, ProgramStore } from "stores";
 
@@ -215,6 +217,10 @@ const Home = () => {
           )}
         </section>
 
+        <section className="cf2021__stats">
+          <StatsCard />
+        </section>
+
         <section className="cf2021__notifications">
           <div className="lg:card lg:elevation-10">
             <div className="lg:card__body pb-2 lg:py-6">
@@ -249,9 +255,18 @@ const Home = () => {
           </div>
 
           <PostsContainer className="container-padding--zero lg:container-padding--auto" />
-          {programEntry.discussionOpened && isAuthenticated && (
-            <AddPostForm className="my-8 space-y-4" />
-          )}
+          {programEntry.discussionOpened &&
+            isAuthenticated &&
+            !user.isBanned && <AddPostForm className="my-8 space-y-4" />}
+
+          {programEntry.discussionOpened &&
+            isAuthenticated &&
+            user.isBanned && (
+              <ErrorMessage className="mt-8">
+                Jejda! Nemůžeš přidávat příspěvky, protože máš ban. Vyčkej než
+                ti ho předsedající odebere.
+              </ErrorMessage>
+            )}
         </section>
       </article>
       <ProgramEntryEditModal
diff --git a/src/stores.js b/src/stores.js
index c7db090..a82900a 100644
--- a/src/stores.js
+++ b/src/stores.js
@@ -3,6 +3,7 @@ import { Store } from "pullstate";
 
 /** @type {CF2021.GlobalInfoStorePayload} */
 const globalInfoStoreInitial = {
+  connectionState: "connecting",
   onlineUsers: 0,
   streamUrl: null,
 };
diff --git a/src/utils.js b/src/utils.js
index 2a80f37..cc62eea 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -3,9 +3,13 @@ import filter from "lodash/filter";
 import pick from "lodash/pick";
 import property from "lodash/property";
 import values from "lodash/values";
+import WaitQueue from "wait-queue";
 
 import { markdownConverter } from "markdown";
 
+export const seenPostsLSKey = "cf2021_seen_posts";
+export const seenAnnouncementsLSKey = "cf2021_seen_announcements";
+
 /**
  * Filter & sort collection of posts.
  * @param {CF2021.PostStoreFilters} filters
@@ -136,7 +140,7 @@ export const parseRawPost = (rawPost) => {
     modified: Boolean(rawPost.is_changed),
     archived: Boolean(rawPost.is_archived),
     hidden: false,
-    seen: false,
+    seen: isSeen(seenPostsLSKey, rawPost.id),
   };
 
   if (post.type === "procedure-proposal") {
@@ -161,8 +165,36 @@ export const parseRawAnnouncement = (rawAnnouncement) => {
       new Date()
     ),
     type: announcementTypeMapping[rawAnnouncement.type],
-    seen: false,
+    seen: isSeen(seenAnnouncementsLSKey, rawAnnouncement.id),
   };
 
   return announcement;
 };
+
+export const createSeenWriter = (localStorageKey) => {
+  const queue = new WaitQueue();
+
+  const seenWriterWorker = async () => {
+    const id = await queue.shift();
+    const seen = new Set(
+      (localStorage.getItem(localStorageKey) || "").split(",")
+    );
+
+    seen.add(id);
+
+    localStorage.setItem(localStorageKey, Array.from(seen).join(","));
+
+    setTimeout(seenWriterWorker);
+  };
+
+  seenWriterWorker();
+
+  return {
+    markSeen: (id) => queue.push(id),
+  };
+};
+
+export const isSeen = (localStorageKey, id) => {
+  const val = localStorage.getItem(localStorageKey) || "";
+  return val.indexOf(id) !== -1;
+};
diff --git a/src/ws/connection.js b/src/ws/connection.js
index df922b2..17f9c2e 100644
--- a/src/ws/connection.js
+++ b/src/ws/connection.js
@@ -1,5 +1,8 @@
+import has from "lodash/has";
 import WaitQueue from "wait-queue";
 
+import { GlobalInfoStore } from "stores";
+
 import { handlers } from "./handlers";
 
 function Worker() {
@@ -17,6 +20,13 @@ function Worker() {
     try {
       const data = JSON.parse(event.data);
 
+      // Special message, TODO: fix
+      if (has(data, "online_users")) {
+        return GlobalInfoStore.update((state) => {
+          state.onlineUsers = data["online_users"];
+        });
+      }
+
       if (!data.event) {
         return console.error("[ws][worker] Missing `event` field");
       }
@@ -46,12 +56,20 @@ export const connect = ({ onConnect }) => {
   return new Promise((resolve, reject) => {
     const worker = Worker();
 
+    GlobalInfoStore.update((state) => {
+      state.connectionState = "connecting";
+    });
     const ws = new WebSocket(process.env.REACT_APP_WS_BASE_URL);
+
     let keepAliveInterval;
     console.log("[ws] Connecting ...");
 
     ws.onopen = () => {
+      GlobalInfoStore.update((state) => {
+        state.connectionState = "connected";
+      });
       console.log("[ws] Connected.");
+      ws.send("CONNECT");
 
       keepAliveInterval = setInterval(() => {
         ws.send("KEEPALIVE");
@@ -70,6 +88,9 @@ export const connect = ({ onConnect }) => {
     ws.onmessage = worker.queue.push.bind(worker.queue);
 
     ws.onclose = (event) => {
+      GlobalInfoStore.update((state) => {
+        state.connectionState = "offline";
+      });
       console.log(
         "[ws] Socket is closed. Reconnect will be attempted in 1 second.",
         event.reason
diff --git a/src/ws/handlers/index.js b/src/ws/handlers/index.js
index b10730e..fb4f300 100644
--- a/src/ws/handlers/index.js
+++ b/src/ws/handlers/index.js
@@ -6,9 +6,11 @@ import {
 import {
   handlePostChanged,
   handlePostCreated,
+  handlePostDeleted,
   handlePostRanked,
 } from "./posts";
 import { handleProgramEntryChanged } from "./program";
+import { handleUserBanned, handleUserUnbanned } from "./users";
 
 export const handlers = {
   announcement_changed: handleAnnouncementChanged,
@@ -17,5 +19,8 @@ export const handlers = {
   post_ranked: handlePostRanked,
   post_changed: handlePostChanged,
   post_created: handlePostCreated,
+  post_deleted: handlePostDeleted,
   program_entry_changed: handleProgramEntryChanged,
+  user_banned: handleUserBanned,
+  user_unbanned: handleUserUnbanned,
 };
diff --git a/src/ws/handlers/posts.js b/src/ws/handlers/posts.js
index 1eb6dc7..d2bb0b3 100644
--- a/src/ws/handlers/posts.js
+++ b/src/ws/handlers/posts.js
@@ -38,6 +38,8 @@ export const handlePostChanged = (payload) => {
       if (has(payload, "is_archived")) {
         state.items[payload.id].archived = payload.is_archived;
       }
+
+      updateWindowPosts(state);
     }
   });
 };
@@ -49,3 +51,10 @@ export const handlePostCreated = (payload) => {
     updateWindowPosts(state);
   });
 };
+
+export const handlePostDeleted = (payload) => {
+  PostStore.update((state) => {
+    delete state.items[payload.id];
+    updateWindowPosts(state);
+  });
+};
diff --git a/src/ws/handlers/users.js b/src/ws/handlers/users.js
new file mode 100644
index 0000000..bf9243f
--- /dev/null
+++ b/src/ws/handlers/users.js
@@ -0,0 +1,17 @@
+import { AuthStore } from "stores";
+
+export const handleUserBanned = (payload) => {
+  AuthStore.update((state) => {
+    if (state.user && state.user.id && payload.id === state.user.id) {
+      state.user.isBanned = true;
+    }
+  });
+};
+
+export const handleUserUnbanned = (payload) => {
+  AuthStore.update((state) => {
+    if (state.user && state.user.id && payload.id === state.user.id) {
+      state.user.isBanned = false;
+    }
+  });
+};
diff --git a/typings/cf2021.d.ts b/typings/cf2021.d.ts
index 33bb981..5b31734 100644
--- a/typings/cf2021.d.ts
+++ b/typings/cf2021.d.ts
@@ -1,5 +1,6 @@
 declare namespace CF2021 {
   export interface GlobalInfoStorePayload {
+    connectionState: "connected" | "offline" | "connecting";
     onlineUsers: number;
     streamUrl?: string;
   }
@@ -40,6 +41,11 @@ declare namespace CF2021 {
       username: string;
       role: "regp" | "member" | "chairman";
       accessToken: string;
+
+      // These are optional as they're loaded separately.
+      id?: number;
+      isBanned?: boolean;
+      group?: string;
     };
   }
 
-- 
GitLab