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