From dcb458b8788ee544c4dacba2e801f645bf976565 Mon Sep 17 00:00:00 2001
From: xaralis <filip.varecha@fragaria.cz>
Date: Sat, 19 Dec 2020 14:54:20 +0100
Subject: [PATCH] feat: detect seen state, hook up posts API, listen on WS for
 post changes

---
 .env                                          |   1 +
 package-lock.json                             |   5 +
 package.json                                  |   3 +-
 src/App.jsx                                   |   2 +
 src/actions/posts.js                          | 209 ++++----------
 src/components/Thumbs.jsx                     |  13 +-
 src/components/annoucements/Announcement.jsx  |  22 +-
 .../annoucements/AnnouncementList.jsx         |   6 +
 src/components/posts/Post.jsx                 |  39 ++-
 src/components/posts/PostList.jsx             |   7 +
 src/containers/AnnoucementsContainer.jsx      |  13 +
 src/containers/PostsContainer.jsx             |  39 +--
 src/index.js                                  |  19 +-
 src/stores.js                                 | 261 +-----------------
 src/utils.js                                  |  56 ++++
 src/ws/connection.js                          |  67 +++++
 src/ws/handlers/index.js                      |   1 +
 src/ws/handlers/posts.js                      |  31 +++
 typings/cf2021.d.ts                           |  12 +-
 19 files changed, 335 insertions(+), 471 deletions(-)
 create mode 100644 src/ws/connection.js
 create mode 100644 src/ws/handlers/index.js
 create mode 100644 src/ws/handlers/posts.js

diff --git a/.env b/.env
index 3096b9a..1270639 100644
--- a/.env
+++ b/.env
@@ -1,2 +1,3 @@
 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/posts
diff --git a/package-lock.json b/package-lock.json
index 0609126..3d3ff0e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11332,6 +11332,11 @@
       "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz",
       "integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA=="
     },
+    "react-intersection-observer": {
+      "version": "8.31.0",
+      "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-8.31.0.tgz",
+      "integrity": "sha512-XraIC/tkrD9JtrmVA7ypEN1QIpKc52mXBH1u/bz/aicRLo8QQEJQAMUTb8mz4B6dqpPwyzgjrr7Ljv/2ACDtqw=="
+    },
     "react-is": {
       "version": "16.13.1",
       "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
diff --git a/package.json b/package.json
index 1c41852..d820587 100644
--- a/package.json
+++ b/package.json
@@ -14,6 +14,7 @@
     "react": "^16.13.1",
     "react-device-detect": "^1.13.1",
     "react-dom": "^16.13.1",
+    "react-intersection-observer": "^8.31.0",
     "react-modal": "^3.12.1",
     "react-router-dom": "^5.2.0",
     "react-scripts": "3.4.3",
@@ -54,7 +55,7 @@
               "^@?\\w"
             ],
             [
-              "^(api|actions|config|hooks|components|containers|pages|utils|stores|keycloak)(/.*|$)"
+              "^(api|actions|config|hooks|components|containers|pages|utils|stores|keycloak|ws)(/.*|$)"
             ],
             [
               "^(test-utils)(/.*|$)"
diff --git a/src/App.jsx b/src/App.jsx
index c9e2933..d2ba396 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -5,6 +5,7 @@ import * as Sentry from "@sentry/react";
 
 import { loadGroupMappings } from "actions/misc";
 import { loadProgram } from "actions/program";
+import { loadPosts } from "actions/posts";
 import Footer from "components/Footer";
 import Navbar from "components/Navbar";
 import Home from "pages/Home";
@@ -58,6 +59,7 @@ const LoadingComponent = (
 const BaseApp = () => {
   loadGroupMappings.read();
   loadProgram.read();
+  loadPosts.read();
 
   return (
     <Router>
diff --git a/src/actions/posts.js b/src/actions/posts.js
index 017c74e..35c8d19 100644
--- a/src/actions/posts.js
+++ b/src/actions/posts.js
@@ -1,64 +1,60 @@
+import keyBy from "lodash/keyBy";
+import property from "lodash/property";
 import { createAsyncAction, errorResult, successResult } from "pullstate";
 
+import { fetch } from "api";
 import { PostStore } from "stores";
-import { updateWindowPosts } from "utils";
-
-export const like = createAsyncAction(
-  /**
-   * @param {CF2021.Post} post
-   */
-  async (post) => {
-    return successResult(post);
+import { filterPosts, parseRawPost, postsTypeMappingRev } from "utils";
+
+export const loadPosts = createAsyncAction(
+  async () => {
+    try {
+      const resp = await fetch("/posts");
+      const data = await resp.json();
+      return successResult(data.data);
+    } catch (err) {
+      return errorResult([], err.toString());
+    }
   },
   {
-    shortCircuitHook: ({ args }) => {
-      if (args.ranking.myVote !== "none") {
-        return errorResult();
-      }
-
-      return false;
-    },
     postActionHook: ({ result }) => {
       if (!result.error) {
-        PostStore.update((state) => {
-          state.items[result.payload.id].ranking.likes += 1;
-          state.items[result.payload.id].ranking.score += 1;
-          state.items[result.payload.id].ranking.myVote = "like";
+        const posts = result.payload.map(parseRawPost);
 
-          if (state.filters.sort === "byScore") {
-            updateWindowPosts(state);
-          }
+        PostStore.update((state) => {
+          const filteredPosts = filterPosts(state.filters, posts);
+
+          state.items = keyBy(posts, property("id"));
+          state.itemCount = state.items.length;
+          state.window = {
+            items: filteredPosts.map(property("id")),
+            itemCount: filteredPosts.length,
+            page: 1,
+            perPage: 5,
+          };
         });
       }
     },
   }
 );
 
-export const removeLike = createAsyncAction(
+export const like = createAsyncAction(
   /**
    * @param {CF2021.Post} post
    */
   async (post) => {
-    return successResult(post);
+    try {
+      await fetch(`/posts/${post.id}/like`, { method: "PATCH" });
+      return successResult(post);
+    } catch (err) {
+      return errorResult([], err.toString());
+    }
   },
   {
-    shortCircuitHook: ({ args }) => {
-      if (args.ranking.myVote !== "like") {
-        return errorResult();
-      }
-
-      return false;
-    },
     postActionHook: ({ result }) => {
       if (!result.error) {
         PostStore.update((state) => {
-          state.items[result.payload.id].ranking.likes -= 1;
-          state.items[result.payload.id].ranking.score -= 1;
-          state.items[result.payload.id].ranking.myVote = "none";
-
-          if (state.filters.sort === "byScore") {
-            updateWindowPosts(state);
-          }
+          state.items[result.payload.id].ranking.myVote = "like";
         });
       }
     },
@@ -70,57 +66,18 @@ export const dislike = createAsyncAction(
    * @param {CF2021.Post} post
    */
   async (post) => {
-    return successResult(post);
+    try {
+      await fetch(`/posts/${post.id}/dislike`, { method: "PATCH" });
+      return successResult(post);
+    } catch (err) {
+      return errorResult([], err.toString());
+    }
   },
   {
-    shortCircuitHook: ({ args }) => {
-      if (args.ranking.myVote !== "none") {
-        return errorResult();
-      }
-
-      return false;
-    },
     postActionHook: ({ result }) => {
       if (!result.error) {
         PostStore.update((state) => {
-          state.items[result.payload.id].ranking.dislikes += 1;
-          state.items[result.payload.id].ranking.score -= 1;
           state.items[result.payload.id].ranking.myVote = "dislike";
-
-          if (state.filters.sort === "byScore") {
-            updateWindowPosts(state);
-          }
-        });
-      }
-    },
-  }
-);
-
-export const removeDislike = createAsyncAction(
-  /**
-   * @param {CF2021.Post} post
-   */
-  async (post) => {
-    return successResult(post);
-  },
-  {
-    shortCircuitHook: ({ args }) => {
-      if (args.ranking.myVote !== "dislike") {
-        return errorResult();
-      }
-
-      return false;
-    },
-    postActionHook: ({ result }) => {
-      if (!result.error) {
-        PostStore.update((state) => {
-          state.items[result.payload.id].ranking.dislikes -= 1;
-          state.items[result.payload.id].ranking.score += 1;
-          state.items[result.payload.id].ranking.myVote = "none";
-
-          if (state.filters.sort === "byScore") {
-            updateWindowPosts(state);
-          }
         });
       }
     },
@@ -130,80 +87,34 @@ export const removeDislike = createAsyncAction(
 /**
  * Add new discussion post.
  */
-export const addPost = createAsyncAction(
-  async ({ content }) => {
-    /** @type {CF2021.DiscussionPost} */
-    const payload = {
-      id: "999",
-      datetime: new Date(),
-      author: {
-        name: "John Doe",
-        group: "KS Pardubický kraj",
-      },
-      type: "post",
+export const addPost = createAsyncAction(async ({ content }) => {
+  try {
+    const body = JSON.stringify({
       content,
-      ranking: {
-        score: 0,
-        likes: 0,
-        dislikes: 0,
-        myVote: "none",
-      },
-      historyLog: [],
-      archived: false,
-      hidden: false,
-      seen: true,
-    };
-    return successResult(payload);
-  },
-  {
-    postActionHook: ({ result }) => {
-      PostStore.update((state) => {
-        state.items[result.payload.id] = result.payload;
-        updateWindowPosts(state);
-      });
-    },
+      type: postsTypeMappingRev["post"],
+    });
+    await fetch(`/posts`, { method: "POST", body });
+    return successResult();
+  } catch (err) {
+    return errorResult([], err.toString());
   }
-);
+});
 
 /**
  * Add new proposal.
  */
-export const addProposal = createAsyncAction(
-  async ({ content }) => {
-    /** @type {CF2021.ProposalPost} */
-    const payload = {
-      id: "999",
-      datetime: new Date(),
-      author: {
-        name: "John Doe",
-        group: "KS Pardubický kraj",
-      },
-      type: "procedure-proposal",
-      state: "pending",
+export const addProposal = createAsyncAction(async ({ content }) => {
+  try {
+    const body = JSON.stringify({
       content,
-      ranking: {
-        score: 0,
-        likes: 0,
-        dislikes: 0,
-        myVote: "none",
-      },
-      historyLog: [],
-      archived: false,
-      hidden: false,
-      seen: true,
-    };
-
-    return successResult(payload);
-  },
-  {
-    postActionHook: ({ result }) => {
-      PostStore.update((state) => {
-        state.items[result.payload.id] = result.payload;
-        updateWindowPosts(state);
-      });
-    },
+      type: postsTypeMappingRev["procedure-proposal"],
+    });
+    await fetch(`/posts`, { method: "POST", body });
+    return successResult();
+  } catch (err) {
+    return errorResult([], err.toString());
   }
-);
+});
 
 /**
  * Hide existing post.
diff --git a/src/components/Thumbs.jsx b/src/components/Thumbs.jsx
index 33f81d8..6f23cce 100644
--- a/src/components/Thumbs.jsx
+++ b/src/components/Thumbs.jsx
@@ -1,27 +1,18 @@
 import React from "react";
-import classNames from "classnames";
 
 const Thumbs = ({ likes, dislikes, onLike, onDislike, myVote }) => {
   return (
     <div>
       <div className="space-x-2 text-sm flex items-center">
         <button
-          className={classNames("text-blue-300 flex items-center space-x-1", {
-            "cursor-pointer": myVote === "none" || myVote === "like",
-            "cursor-default": myVote === "dislike",
-          })}
-          disabled={myVote === "dislike"}
+          className="text-blue-300 flex items-center space-x-1 cursor-pointer"
           onClick={onLike}
         >
           <span className="font-bold">{likes}</span>
           <i className="ico--thumbs-up"></i>
         </button>
         <button
-          className={classNames("text-red-600 flex items-center space-x-1", {
-            "cursor-pointer": myVote === "none" || myVote === "dislike",
-            "cursor-default": myVote === "like",
-          })}
-          disabled={myVote === "like"}
+          className="text-red-600 flex items-center space-x-1 cursor-pointer"
           onClick={onDislike}
         >
           <i className="ico--thumbs-down transform -scale-x-1"></i>
diff --git a/src/components/annoucements/Announcement.jsx b/src/components/annoucements/Announcement.jsx
index 0ccea11..34a3902 100644
--- a/src/components/annoucements/Announcement.jsx
+++ b/src/components/annoucements/Announcement.jsx
@@ -1,4 +1,5 @@
-import React from "react";
+import React, { useEffect } from "react";
+import { useInView } from "react-intersection-observer";
 import classNames from "classnames";
 import { format } from "date-fns";
 
@@ -16,9 +17,24 @@ const Announcement = ({
   displayActions = false,
   onDelete,
   onEdit,
+  onSeen,
 }) => {
+  const { ref, inView } = useInView({
+    threshold: 1,
+    trackVisibility: true,
+    delay: 1500,
+    skip: seen,
+    triggerOnce: true,
+  });
+
+  useEffect(() => {
+    if (!seen && inView && onSeen) {
+      onSeen();
+    }
+  });
+
   const wrapperClassName = classNames(
-    "bg-opacity-50 border-l-2 px-4 py-2 lg:px-8 lg:py-3",
+    "bg-opacity-50 border-l-2 px-4 py-2 lg:px-8 lg:py-3 transition duration-500",
     {
       "bg-grey-50": !!seen,
       "bg-yellow-100": !seen,
@@ -60,7 +76,7 @@ const Announcement = ({
   ].includes(type);
 
   return (
-    <div className={wrapperClassName}>
+    <div className={wrapperClassName} ref={ref}>
       <div className="flex items-center justify-between mb-2">
         <div className="space-x-2 flex items-center">
           <div className="font-bold text-sm">{format(datetime, "H:mm")}</div>
diff --git a/src/components/annoucements/AnnouncementList.jsx b/src/components/annoucements/AnnouncementList.jsx
index b681cfb..693cf9d 100644
--- a/src/components/annoucements/AnnouncementList.jsx
+++ b/src/components/annoucements/AnnouncementList.jsx
@@ -9,6 +9,7 @@ const AnnouncementList = ({
   displayActions,
   onDelete,
   onEdit,
+  onSeen,
 }) => {
   const buildHandler = (responderFn) => (post) => (evt) => {
     evt.preventDefault();
@@ -18,6 +19,10 @@ const AnnouncementList = ({
   const onAnnouncementEdit = buildHandler(onEdit);
   const onAnnouncementDelete = buildHandler(onDelete);
 
+  const onAnnouncementSeen = (announcement) => () => {
+    onSeen(announcement);
+  };
+
   return (
     <div className={classNames("space-y-px", className)}>
       {items.map((item) => (
@@ -31,6 +36,7 @@ const AnnouncementList = ({
           displayActions={displayActions}
           onEdit={onAnnouncementEdit(item)}
           onDelete={onAnnouncementDelete(item)}
+          onSeen={onAnnouncementSeen(item)}
         />
       ))}
     </div>
diff --git a/src/components/posts/Post.jsx b/src/components/posts/Post.jsx
index 88c6f53..d78acdb 100644
--- a/src/components/posts/Post.jsx
+++ b/src/components/posts/Post.jsx
@@ -1,4 +1,5 @@
-import React from "react";
+import React, { useEffect } from "react";
+import { useInView } from "react-intersection-observer";
 import classNames from "classnames";
 import { format } from "date-fns";
 
@@ -13,10 +14,10 @@ const Post = ({
   type,
   ranking,
   content,
+  modified,
   seen,
   archived,
   state,
-  historyLog,
   dimIfArchived = true,
   displayActions = false,
   onLike,
@@ -26,13 +27,27 @@ const Post = ({
   onAnnounceProcedureProposal,
   onAcceptProcedureProposal,
   onRejectProcedureProposal,
+  onSeen,
 }) => {
+  const { ref, inView } = useInView({
+    threshold: 0.9,
+    trackVisibility: true,
+    delay: 1500,
+    skip: seen,
+    triggerOnce: true,
+  });
+
+  useEffect(() => {
+    if (!seen && inView && onSeen) {
+      onSeen();
+    }
+  });
+
   const wrapperClassName = classNames(
-    "flex items-start p-4 lg:p-2 lg:py-3 lg:-mx-2",
+    "flex items-start p-4 lg:p-2 lg:py-3 lg:-mx-2 transition duration-500",
     {
       "bg-yellow-100 bg-opacity-50": !seen,
-      "opacity-25 hover:opacity-100 transition-opacity duration-200":
-        dimIfArchived && !!archived,
+      "opacity-25 hover:opacity-100": dimIfArchived && !!archived,
     },
     className
   );
@@ -103,12 +118,6 @@ const Post = ({
     );
   }
 
-  const isModified =
-    (historyLog || []).filter(
-      (logRecord) =>
-        logRecord.attribute === "content" && logRecord.originator === "chairman"
-    ).length > 0;
-
   const showAnnounceAction =
     type === "procedure-proposal" && state === "pending";
   const showAcceptAction =
@@ -119,10 +128,10 @@ const Post = ({
   const showHideAction = !archived;
 
   return (
-    <div className={wrapperClassName}>
+    <div className={wrapperClassName} ref={ref}>
       <img
-        src="http://placeimg.com/100/100/people"
-        className="w-8 h-8 lg:w-14 lg:h-14 rounded mr-3"
+        src={`https://a.pirati.cz/piratar/${author.username}.jpg`}
+        className="w-8 h-8 lg:w-14 lg:h-14 rounded mr-3 object-cover"
         alt={author.name}
       />
       <div className="flex-1">
@@ -135,7 +144,7 @@ const Post = ({
                   <span className="text-grey-200 text-sm">{author.group}</span>
                   <span className="text-grey-200 ml-1 text-sm">
                     @ {format(datetime, "H:mm")}
-                    {isModified && (
+                    {modified && (
                       <span className="text-grey-200 text-xs ml-2 underline">
                         (Upraveno přesdedajícím)
                       </span>
diff --git a/src/components/posts/PostList.jsx b/src/components/posts/PostList.jsx
index 9ff1ad8..0984dfa 100644
--- a/src/components/posts/PostList.jsx
+++ b/src/components/posts/PostList.jsx
@@ -13,6 +13,7 @@ const PostList = ({
   onAnnounceProcedureProposal,
   onAcceptProcedureProposal,
   onRejectProcedureProposal,
+  onSeen,
   dimArchived,
 }) => {
   const buildHandler = (responderFn) => (post) => (evt) => {
@@ -30,6 +31,10 @@ const PostList = ({
   const onPostAcceptProcedureProposal = buildHandler(onAcceptProcedureProposal);
   const onPostRejectProcedureProposal = buildHandler(onRejectProcedureProposal);
 
+  const onPostSeen = (post) => () => {
+    onSeen(post);
+  };
+
   return (
     <div className={classNames("space-y-px", className)}>
       {items
@@ -44,6 +49,7 @@ const PostList = ({
             content={item.content}
             ranking={item.ranking}
             historyLog={item.historyLog}
+            modified={item.modified}
             seen={item.seen}
             archived={item.archived}
             displayActions={true}
@@ -55,6 +61,7 @@ const PostList = ({
             onAnnounceProcedureProposal={onPostAnnounceProcedureProposal(item)}
             onAcceptProcedureProposal={onPostAcceptProcedureProposal(item)}
             onRejectProcedureProposal={onPostRejectProcedureProposal(item)}
+            onSeen={onPostSeen(item)}
           />
         ))}
     </div>
diff --git a/src/containers/AnnoucementsContainer.jsx b/src/containers/AnnoucementsContainer.jsx
index 6ac1c56..4cd9789 100644
--- a/src/containers/AnnoucementsContainer.jsx
+++ b/src/containers/AnnoucementsContainer.jsx
@@ -9,6 +9,7 @@ import AnnouncementList from "components/annoucements/AnnouncementList";
 import ModalConfirm from "components/modals/ModalConfirm";
 import { useItemActionConfirm } from "hooks";
 import { AnnouncementStore } from "stores";
+import findIndex from "lodash/findIndex";
 
 const AnnoucementsContainer = () => {
   const [itemToEdit, setItemToEdit] = useState(null);
@@ -36,6 +37,17 @@ const AnnoucementsContainer = () => {
     setItemToEdit(null);
   }, [setItemToEdit]);
 
+  /**
+   * Mark down user saw this post already.
+   * @param {CF2021.Announcement} post
+   */
+  const markSeen = (announcement) => {
+    AnnouncementStore.update((state) => {
+      const idx = findIndex(state.items, announcement);
+      state.items[idx].seen = true;
+    });
+  };
+
   return (
     <>
       <AnnouncementList
@@ -43,6 +55,7 @@ const AnnoucementsContainer = () => {
         displayActions={true}
         onDelete={setItemToDelete}
         onEdit={setItemToEdit}
+        onSeen={markSeen}
       />
       <ModalConfirm
         isOpen={!!itemToDelete}
diff --git a/src/containers/PostsContainer.jsx b/src/containers/PostsContainer.jsx
index 7608cbe..5f7a01a 100644
--- a/src/containers/PostsContainer.jsx
+++ b/src/containers/PostsContainer.jsx
@@ -8,8 +8,6 @@ import {
   hide,
   like,
   rejectProposal,
-  removeDislike,
-  removeLike,
 } from "actions/posts";
 import { ban } from "actions/users";
 import ModalConfirm from "components/modals/ModalConfirm";
@@ -57,37 +55,21 @@ const PostsContainer = ({ className }) => {
   );
 
   /**
-   *
-   * @param {CF2021.Post} post
-   */
-  const onLike = (post) => {
-    if (post.ranking.myVote === "none") {
-      return like.run(post);
-    }
-    if (post.ranking.myVote === "like") {
-      return removeLike.run(post);
-    }
-  };
-
-  /**
-   *
+   * Ban a post's author.
    * @param {CF2021.Post} post
    */
-  const onDislike = (post) => {
-    if (post.ranking.myVote === "none") {
-      return dislike.run(post);
-    }
-    if (post.ranking.myVote === "dislike") {
-      return removeDislike.run(post);
-    }
+  const onBanUser = (post) => {
+    setUserToBan(post.author);
   };
 
   /**
-   * Ban a post's author.
+   * Mark down user saw this post already.
    * @param {CF2021.Post} post
    */
-  const onBanUser = (post) => {
-    setUserToBan(post.author);
+  const markSeen = (post) => {
+    PostStore.update((state) => {
+      state.items[post.id].seen = true;
+    });
   };
 
   const sliceStart = (window.page - 1) * window.perPage;
@@ -99,8 +81,9 @@ const PostsContainer = ({ className }) => {
         items={window.items
           .slice(sliceStart, sliceEnd)
           .map((postId) => items[postId])}
-        onLike={onLike}
-        onDislike={onDislike}
+        onLike={like.run}
+        onDislike={dislike.run}
+        onSeen={markSeen}
         className={className}
         dimArchived={!showingArchivedOnly}
         displayActions={true}
diff --git a/src/index.js b/src/index.js
index f22bcc4..60f1b43 100644
--- a/src/index.js
+++ b/src/index.js
@@ -2,18 +2,23 @@ import React from "react";
 import ReactDOM from "react-dom";
 import ReactModal from "react-modal";
 
+import { connect } from "./ws/connection";
 import App from "./App";
 import * as serviceWorker from "./serviceWorker";
 
 const root = document.getElementById("root");
 
-ReactDOM.render(
-  <React.StrictMode>
-    <App />
-  </React.StrictMode>,
-  root
-);
-ReactModal.setAppElement(root);
+const render = () => {
+  ReactDOM.render(
+    <React.StrictMode>
+      <App />
+    </React.StrictMode>,
+    root
+  );
+  ReactModal.setAppElement(root);
+};
+
+connect().then(render);
 
 // If you want your app to work offline and load faster, you can change
 // unregister() to register() below. Note this comes with some pitfalls.
diff --git a/src/stores.js b/src/stores.js
index 599d26c..95586ef 100644
--- a/src/stores.js
+++ b/src/stores.js
@@ -1,10 +1,6 @@
-import keyBy from "lodash/keyBy";
 import memoize from "lodash/memoize";
-import property from "lodash/property";
 import { Store } from "pullstate";
 
-import { filterPosts } from "utils";
-
 /** @type {CF2021.AuthStorePayload} */
 const authStoreInitial = {
   isAuthenticated: false,
@@ -77,261 +73,22 @@ const announcementStoreInitial = {
 
 export const AnnouncementStore = new Store(announcementStoreInitial);
 
-const allPosts = [
-  {
-    id: "1",
-    content:
-      "Shizz fo shizzle mah nizzle fo rizzle, mah home g-dizzle, gravida vizzle, arcu. Pellentesque crunk tortizzle. Sed erizzle. Black izzle sheezy telliv.",
-    datetime: new Date(),
-    author: {
-      id: 1,
-      name: "John Doe",
-      group: "cf",
-    },
-    ranking: {
-      likes: 0,
-      dislikes: 0,
-      score: 0,
-      myVote: "none",
-    },
-    seen: false,
-    archived: false,
-    type: "post",
-  },
-  {
-    id: "2",
-    content:
-      "Shizz fo shizzle mah nizzle fo rizzle, mah home g-dizzle, gravida vizzle, arcu. Pellentesque crunk tortizzle. Sed erizzle. Black izzle sheezy telliv.",
-    datetime: new Date(),
-    author: {
-      id: 1,
-      name: "John Doe",
-      group: "cf",
-    },
-    ranking: {
-      likes: 1,
-      dislikes: 0,
-      score: 1,
-      myVote: "none",
-    },
-    seen: false,
-    archived: false,
-    type: "post",
-  },
-  {
-    id: "3",
-    content:
-      "Shizz fo shizzle mah nizzle fo rizzle, mah home g-dizzle, gravida vizzle, arcu. Pellentesque crunk tortizzle. Sed erizzle. Black izzle sheezy telliv.",
-    datetime: new Date(),
-    author: {
-      id: 1,
-      name: "John Doe",
-      group: "cf",
-    },
-    ranking: {
-      likes: 5,
-      dislikes: 5,
-      score: 0,
-      myVote: "none",
-    },
-    seen: true,
-    archived: false,
-    type: "post",
-  },
-  {
-    id: "4",
-    content:
-      "Shizz fo shizzle mah nizzle fo rizzle, mah home g-dizzle, gravida vizzle, arcu. Pellentesque crunk tortizzle. Sed erizzle. Black izzle sheezy telliv.",
-    datetime: new Date(),
-    author: {
-      id: 1,
-      name: "John Doe",
-      group: "KS Pardubický kraj",
-    },
-    ranking: {
-      likes: 0,
-      dislikes: 10,
-      score: -10,
-      myVote: "none",
-    },
-    seen: true,
-    archived: false,
-    type: "post",
-  },
-  {
-    id: "5",
-    content:
-      "Shizz fo shizzle mah nizzle fo rizzle, mah home g-dizzle, gravida vizzle, arcu. Pellentesque crunk tortizzle. Sed erizzle. Black izzle sheezy telliv.",
-    datetime: new Date(),
-    author: {
-      id: 1,
-      name: "John Doe",
-      group: "KS Pardubický kraj",
-    },
-    ranking: {
-      likes: 1,
-      dislikes: 1,
-      score: 0,
-      myVote: "none",
-    },
-    seen: true,
-    archived: false,
-    type: "post",
-  },
-  {
-    id: "6",
-    content:
-      "Shizz fo shizzle mah nizzle fo rizzle, mah home g-dizzle, gravida vizzle, arcu. Pellentesque crunk tortizzle. Sed erizzle. Black izzle sheezy telliv.",
-    datetime: new Date(),
-    author: {
-      id: 1,
-      name: "John Doe",
-      group: "KS Pardubický kraj",
-    },
-    ranking: {
-      likes: 5,
-      dislikes: 3,
-      score: 2,
-      myVote: "none",
-    },
-    seen: true,
-    archived: true,
-    type: "post",
-  },
-  {
-    id: "7",
-    content:
-      "Shizz fo shizzle mah nizzle fo rizzle, mah home g-dizzle, gravida vizzle, arcu. Pellentesque crunk tortizzle. Sed erizzle. Black izzle sheezy telliv.",
-    datetime: new Date(),
-    author: {
-      id: 1,
-      name: "John Doe",
-      group: "KS Pardubický kraj",
-    },
-    ranking: {
-      likes: 5,
-      dislikes: 8,
-      score: -3,
-      myVote: "none",
-    },
-    seen: true,
-    archived: true,
-    type: "procedure-proposal",
-    state: "pending",
-  },
-  {
-    id: "8",
-    content:
-      "Shizz fo shizzle mah nizzle fo rizzle, mah home g-dizzle, gravida vizzle, arcu. Pellentesque crunk tortizzle. Sed erizzle. Black izzle sheezy telliv.",
-    datetime: new Date(),
-    author: {
-      id: 1,
-      name: "John Doe",
-      group: "KS Pardubický kraj",
-    },
-    ranking: {
-      likes: 2,
-      dislikes: 1,
-      score: 1,
-      myVote: "like",
-    },
-    seen: true,
-    archived: false,
-    type: "procedure-proposal",
-    state: "announced",
-    historyLog: [
-      {
-        attribute: "content",
-        datetime: new Date(),
-        newValue: "Lemme know",
-        originator: "chairman",
-      },
-    ],
-  },
-  {
-    id: "9",
-    content:
-      "Shizz fo shizzle mah nizzle fo rizzle, mah home g-dizzle, gravida vizzle, arcu. Pellentesque crunk tortizzle. Sed erizzle. Black izzle sheezy telliv.",
-    datetime: new Date(),
-    author: {
-      id: 1,
-      name: "John Doe",
-      group: "KS Pardubický kraj",
-    },
-    ranking: {
-      likes: 5,
-      dislikes: 0,
-      score: 5,
-      myVote: "dislike",
-    },
-    seen: true,
-    archived: false,
-    type: "procedure-proposal",
-    state: "accepted",
-  },
-  {
-    id: "10",
-    content:
-      "Shizz fo shizzle mah nizzle fo rizzle, mah home g-dizzle, gravida vizzle, arcu. Pellentesque crunk tortizzle. Sed erizzle. Black izzle sheezy telliv.",
-    datetime: new Date(),
-    author: {
-      id: 1,
-      name: "John Doe",
-      group: "KS Pardubický kraj",
-    },
-    ranking: {
-      likes: 5,
-      dislikes: 8,
-      score: -3,
-      myVote: "none",
-    },
-    seen: true,
-    archived: false,
-    type: "procedure-proposal",
-    state: "rejected",
-  },
-  {
-    id: "11",
-    content:
-      "Shizz fo shizzle mah nizzle fo rizzle, mah home g-dizzle, gravida vizzle, arcu. Pellentesque crunk tortizzle. Sed erizzle. Black izzle sheezy telliv.",
-    datetime: new Date(),
-    author: {
-      id: 1,
-      name: "John Doe",
-      group: "KS Pardubický kraj",
-    },
-    ranking: {
-      likes: 10,
-      dislikes: 1,
-      score: 9,
-      myVote: "none",
-    },
-    seen: true,
-    archived: true,
-    type: "procedure-proposal",
-    state: "rejected-by-chairman",
-  },
-];
-
-const initialPostFilters = {
-  flags: "all",
-  sort: "byDate",
-  type: "all",
-};
-
-const filteredPosts = filterPosts(initialPostFilters, allPosts);
 
 /** @type {CF2021.PostStorePayload} */
 const postStoreInitial = {
-  items: keyBy(allPosts, property("id")),
-  itemCount: allPosts.length,
+  items: {},
+  itemCount: 0,
   window: {
-    items: filteredPosts.map(property("id")),
-    itemCount: filteredPosts.length,
+    items: [],
+    itemCount: 0,
     page: 1,
     perPage: 5,
   },
-  filters: initialPostFilters,
+  filters: {
+    flags: "all",
+    sort: "byDate",
+    type: "all",
+  },
 };
 
 export const PostStore = new Store(postStoreInitial);
diff --git a/src/utils.js b/src/utils.js
index 8e8cb35..367e951 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -1,6 +1,7 @@
 import filter from "lodash/filter";
 import property from "lodash/property";
 import values from "lodash/values";
+import pick from "lodash/pick";
 
 /**
  * Filter & sort collection of posts.
@@ -43,3 +44,58 @@ export const updateWindowPosts = (state) => {
     property("id")
   );
 };
+
+export const postsMyVoteMapping = {
+  0: "none",
+  1: "like",
+  [-1]: "dislike",
+};
+
+export const postsTypeMapping = {
+  0: "post",
+  1: "procedure-proposal",
+};
+
+export const postsTypeMappingRev = {
+  post: 0,
+  "procedure-proposal": 1,
+};
+
+export const postsStateMapping = {
+  0: "pending",
+  1: "announced",
+  2: "accepted",
+  3: "rejected",
+  4: "rejected-by-chairman",
+};
+
+/**
+ * Parse single post from the API.
+ *
+ * @param {any} rawPost
+ * @returns {CF2021.Post}
+ */
+export const parseRawPost = (rawPost) => {
+  const post = {
+    ...pick(rawPost, ["id", "content", "author"]),
+    datetime: new Date(rawPost.datetime),
+    historyLog: rawPost.history_log,
+    ranking: {
+      dislikes: rawPost.ranking.dislikes,
+      likes: rawPost.ranking.likes,
+      score: rawPost.ranking.score,
+      myVote: postsMyVoteMapping[rawPost.ranking.my_vote],
+    },
+    type: postsTypeMapping[rawPost.type],
+    modified: Boolean(rawPost.is_changed),
+    archived: Boolean(rawPost.is_archived),
+    hidden: false,
+    seen: false,
+  };
+
+  if (post.type === "procedure-proposal") {
+    post.state = postsStateMapping[post.state];
+  }
+
+  return post;
+};
diff --git a/src/ws/connection.js b/src/ws/connection.js
new file mode 100644
index 0000000..5d4ae39
--- /dev/null
+++ b/src/ws/connection.js
@@ -0,0 +1,67 @@
+import { handleRanking } from "./handlers";
+
+const handlerMap = {
+  ranked: handleRanking,
+};
+
+const messageRouter = (event) => {
+  console.debug("[ws] New message", event.data);
+
+  try {
+    const data = JSON.parse(event.data);
+
+    if (!data.event) {
+      return console.error("[ws] Missing `event` field");
+    }
+
+    const handlerFn = handlerMap[data.event];
+
+    if (!handlerFn) {
+      console.warn(`[ws] Can't handle event '${data.event}'`);
+    }
+
+    handlerFn(data.payload);
+  } catch (err) {
+    console.error("[ws] Could not parse message as JSON.");
+  }
+};
+
+export const connect = () => {
+  return new Promise((resolve, reject) => {
+    const ws = new WebSocket(process.env.REACT_APP_WS_BASE_URL);
+    let keepAlive;
+    console.log("[ws] Connecting ...");
+
+    ws.onopen = () => {
+      console.log("[ws] Connected.");
+      resolve(ws);
+
+      keepAlive = setInterval(() => {
+        ws.send("KEEPALIVE");
+        console.debug("[ws] Sending keepalive.");
+      }, 30 * 1000);
+    };
+
+    ws.onmessage = messageRouter;
+
+    ws.onclose = (event) => {
+      console.log(
+        "[ws] Socket is closed. Reconnect will be attempted in 1 second.",
+        event.reason
+      );
+
+      clearInterval(keepAlive);
+      setTimeout(connect, 1000);
+    };
+
+    ws.onerror = (err) => {
+      console.error(
+        "[ws] Socket encountered error: ",
+        err.message,
+        "Closing socket"
+      );
+      ws.close();
+      reject(err);
+    };
+  });
+};
diff --git a/src/ws/handlers/index.js b/src/ws/handlers/index.js
new file mode 100644
index 0000000..4f89127
--- /dev/null
+++ b/src/ws/handlers/index.js
@@ -0,0 +1 @@
+export * from "./posts";
diff --git a/src/ws/handlers/posts.js b/src/ws/handlers/posts.js
new file mode 100644
index 0000000..ecd3e27
--- /dev/null
+++ b/src/ws/handlers/posts.js
@@ -0,0 +1,31 @@
+import { PostStore } from "stores";
+import { parseRawPost, updateWindowPosts } from "utils";
+
+export const handleRanking = (payload) => {
+  PostStore.update((state) => {
+    state.items[payload.id].ranking.likes = payload["ranking_likes"];
+    state.items[payload.id].ranking.dislikes = payload["ranking_dislikes"];
+    state.items[payload.id].ranking.score =
+      state.items[payload.id].ranking.likes -
+      state.items[payload.id].ranking.dislikes;
+
+    if (state.filters.sort === "byScore") {
+      updateWindowPosts(state);
+    }
+  });
+};
+
+export const handleChanged = (payload) => {
+  PostStore.update((state) => {
+    state.items[payload.id].content = payload.content;
+    state.items[payload.id].modified = true;
+  });
+};
+
+export const handleCreated = (payload) => {
+  PostStore.update((state) => {
+    state.items[payload.id] = parseRawPost(payload);
+
+    updateWindowPosts(state);
+  });
+};
diff --git a/typings/cf2021.d.ts b/typings/cf2021.d.ts
index 35ff79b..048e486 100644
--- a/typings/cf2021.d.ts
+++ b/typings/cf2021.d.ts
@@ -66,11 +66,12 @@ declare namespace CF2021 {
     export type PostType = "post" | "procedure-proposal";
 
     export interface AbstractPost {
-        id: string;
+        id: number;
         datetime: Date;
         author: {
             id: number;
             name: string;
+            username: string;
             group: string;
         };
         type: PostType;
@@ -82,11 +83,12 @@ declare namespace CF2021 {
             myVote: "like" | "dislike" | "none";
         };
         historyLog: {
-            attribute: string;
-            newValue: string;
-            datetime: Date;
-            originator: "self" | "chairman";
+          attribute: string;
+          newValue: string;
+          datetime: Date;
+          originator: "self" | "chairman";
         }[];
+        modified: boolean;
         archived: boolean;
         hidden: boolean;
         seen: boolean;
-- 
GitLab